Аудит символьных ссылок в CI облачного Mac

Аудит символьных ссылок в CI облачного Mac

Один и тот же проект может успешно архивироваться на компьютере разработчика, но выдавать ошибку об отсутствии ресурса в CI облачного Mac. Одна из причин, которую чаще всего упускают из виду, — символьные ссылки. Файл, выглядящий в репозитории как обычный, на самом деле может указывать на локальный каталог, незакоммиченную цель или путь за пределами рабочего каталога. Xcode обращается к нему лишь при копировании ресурсов, выполнении скриптов или упаковке зависимостей, поэтому ошибка проявляется только на позднем этапе сборки, а в журнале часто выглядит как обычное сообщение «файл не найден».

Сначала определите допустимые ссылки

Не начинайте со скрипта сканирования — сначала четко сформулируйте правила. Для непрерывной сборки обычно достаточно трех базовых требований: ссылка должна использовать относительный путь, ее цель после разрешения должна существовать, а итоговый путь не должен выходить за пределы текущего рабочего каталога. Для внутренних ссылок, создаваемых отдельными инструментами, допустимы исключения, но каждое из них должно задаваться полным относительным путем.

Задача контроля символьных ссылок — не запретить их, а устранить неявную зависимость от структуры каталогов конкретного компьютера.

Например, Config/current.json -> release.json будет стабильно работать после клонирования репозитория, тогда как SDK/current -> /Users/dev/SDK переносит в конвейер состояние локальной машины. Путь ../Shared/file также нельзя оценивать только по его текстовой записи: он может оставаться внутри репозитория, а может выходить за пределы рабочего каталога, выделенного CI. Поэтому сначала необходимо разрешить и нормализовать путь, а уже затем проверять его границы.

Проверка Допустимо Следует блокировать
Форма ссылки Относительный путь внутри репозитория Абсолютный путь
Состояние цели Цель существует и доступна для чтения Разорванная или циклическая ссылка
Границы пути После разрешения находится в рабочем каталоге Указывает за пределы рабочего каталога
Правила исключений Полный относительный путь Широкий префикс каталога

Подтвердите тип ссылки по индексу Git

Проверяйте режим, а не расширение файла

Git хранит символьные ссылки с режимом 120000, а путь к цели — как содержимое файла. Сначала получите их список непосредственно из индекса, чтобы не смешивать отслеживаемые ссылки с обычными текстовыми файлами или ссылками, созданными во время сборки.

git ls-files -s | awk '$1 == "120000" { print $4 }'
git config --show-origin --get core.symlinks || true
git status --porcelain=v1

После извлечения репозитория в macOS такие объекты должны действительно быть ссылками. Если в состоянии репозитория внезапно видно, что ссылка заменена обычным файлом, сначала проверьте, не изменили ли тип файла этапы упаковки, распаковки или синхронизации. После чистого извлечения репозитория контроль также должен один раз выполнить git diff --exit-code, чтобы убедиться, что скрипты инициализации незаметно не изменили записанный в индексе текст ссылки.

Найдите неотслеживаемые цели

То, что сама ссылка закоммичена, еще не означает, что ее цель тоже добавлена в репозиторий. Для каждой ссылки, отслеживаемой Git, следует проверить существование цели, а затем с помощью git ls-files --error-unmatch убедиться, что цель внутри репозитория находится под контролем версий. Если цель должна создаваться во время сборки, перенесите проверку на этап после ее генерации, сохранив при этом точный список целей, которым разрешено временно отсутствовать до генерации.

Блокируйте разорванные и выходящие за границы ссылки с помощью скрипта

Приведенный ниже скрипт рекурсивно проверяет указанный каталог. Он отклоняет абсолютные ссылки, цели, которые невозможно разрешить, а также цели, чей разрешенный путь находится за пределами корневого каталога. Сохраните его как ci/check_symlinks.py и запускайте после установки зависимостей, но до вызова xcodebuild.

import os
import sys
from pathlib import Path

root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
failures = []

for path in root.rglob("*"):
    if not path.is_symlink():
        continue

    raw = os.readlink(path)
    relative = path.relative_to(root)

    if os.path.isabs(raw):
        failures.append((relative, "absolute", raw))
        continue

    try:
        target = path.resolve(strict=True)
    except (FileNotFoundError, RuntimeError, OSError) as error:
        failures.append((relative, "broken", str(error)))
        continue

    try:
        target.relative_to(root)
    except ValueError:
        failures.append((relative, "outside", str(target)))

for item in failures:
    print("\t".join(map(str, item)))

sys.exit(1 if failures else 0)

Запускать его следует так:

python3 ci/check_symlinks.py "$PWD"

Не сравнивайте пути простым поиском строкового префикса. Строка /tmp/job-10 начинается с /tmp/job-1, но этот каталог не является его подкаталогом. Чтобы избежать подобных ложных результатов, нормализуйте пути и сравнивайте их с учетом иерархии каталогов.

Выполняйте проверку до и после сборки

Сканирование исходного кода выявляет только уже существующие в репозитории проблемы. Менеджеры зависимостей, генераторы кода и пользовательские этапы Build Phase также могут создавать новые ссылки. Поэтому рекомендуется предусмотреть две контрольные точки.

Проверяйте рабочий каталог перед сборкой

После разрешения зависимостей и генерации кода просканируйте весь рабочий каталог, исключив только те кэш-каталоги, которые однозначно относятся к внутренней реализации инструментов и не попадают в конечный артефакт. Правила исключения также должны храниться в репозитории: нельзя позволять временным переменным окружения на runner произвольно расширять их. Затем сохраните список ссылок, чтобы позднее определить, какие из них появились во время сборки.

find "$PWD" -type l -print | LC_ALL=C sort > "$TMPDIR/symlinks-before.txt"
python3 ci/check_symlinks.py "$PWD"

Проверяйте артефакт после сборки

После завершения архивирования снова запустите тот же скрипт для экспортированного .app или корневого каталога распакованного артефакта. Допустимая ссылка должна одновременно удовлетворять двум требованиям: ее цель остается внутри артефакта, а сам путь входит в список разрешенных. Если сторонний фреймворк действительно содержит внутренние ссылки, разрешайте конкретные пути, а не весь каталог Frameworks.

Затем сравните списки до и после сборки. Если новые ссылки появились на этапе выполнения скрипта, им должна соответствовать конкретная команда генерации. Любые необъяснимые новые элементы следует сначала заблокировать для выпуска, а затем определить создавший их процесс по журналу сборки.

Обрабатывайте типичные ложные срабатывания и оформляйте контроль CI

Чаще всего ложные срабатывания связаны с временными каталогами, кэшами зависимостей и внутренней структурой архивов. Сначала следует сузить корень сканирования, затем добавить точные исключения и лишь в последнюю очередь рассматривать исключение целых каталогов. Не пропускайте все дерево зависимостей только потому, что одна зависимость создает ссылку: иначе вместе с ней будут скрыты и реальные разорванные ссылки.

При ошибке контроль должен выводить как минимум относительный путь ссылки, тип сбоя и разрешенную цель, а код завершения скрипта следует напрямую передавать CI. После исправления выполните проверку на совершенно новом извлечении репозитория, а не только повторно в рабочем каталоге, где уже запускались скрипты инициализации. Итоговый контроль можно свести к шести условиям: корректный режим Git, цель находится под контролем версий, ссылка использует относительный путь, разрешенный путь не выходит за границы, новые ссылки сборки имеют понятное происхождение, а исключения для архива совпадают с точными путями. При соблюдении этих шести условий символьные ссылки перестают быть случайной проблемой окружения и становятся проверяемым инженерным контрактом.

Часто задаваемые вопросы

Почему рабочая локальная ссылка ломается в CI облачного Mac?

Она может зависеть от абсолютного пути, вести к неотслеживаемому файлу или предполагать другое расположение checkout. Проверяйте текст ссылки и фактический путь назначения.

Нужно ли запрещать все символьные ссылки в репозитории?

Нет. Стабильные относительные ссылки внутри репозитория допустимы. Запрещайте битые и абсолютные ссылки, а также выход за пределы рабочего каталога.

Как разрешить допустимую ссылку внутри архива?

Сначала убедитесь, что назначение остаётся внутри корня архива, затем добавьте точный относительный путь в список исключений. Не используйте широкие префиксы.

Запустите следующую задачу

Выберите облачный Mac под свою рабочую нагрузку

Проверьте чип, объём памяти, хранилище, срок аренды и регион, затем закажите выделенный физический сервер Apple Silicon.

Выбрать модель и заказать