Права скриптов и переводы строк в Mac CI

DevOps и CI/CD ·~5 мин чтения

Права скриптов и переводы строк в Mac CI

Скрипт сборки может без ошибок работать на компьютере разработчика, а после переноса на удалённый Mac завершаться сообщением Permission denied, bad interpreter или необъяснимым $'\r' в конце строки. Обычно причина не связана с прикладным кодом: режим файла в Git, переводы строк и фактически используемый интерпретатор не образуют единого контракта. Сначала сохраните команду, завершившуюся ошибкой, идентификатор коммита и путь к скрипту, а затем последовательно выполните проверки ниже. Как правило, это быстрее, чем многократно очищать кеши.

Сначала определите, на каком уровне возникает ошибка

Не следует сразу добавлять chmod +x, увидев сообщение об ошибке скрипта. У Shell-скрипта есть как минимум четыре уровня состояния: права в рабочем дереве, режим, записанный в индексе Git, фактические байты файла и способ его запуска runner-ом.

script="ci/build.sh"

ls -l "$script"
git ls-files -s -- "$script"
file "$script"
head -n 1 "$script" | cat -vet

В выводе git ls-files -s значение 100755 в первом столбце означает, что Git записал бит исполнения, а 100644 обозначает обычный файл. Если cat -vet показывает ^M в конце строки, файл содержит переводы строк CRLF. Убедитесь также, что первая строка содержит корректный shebang, например #!/bin/zsh или #!/usr/bin/env bash.

Способ запуска тоже необходимо зафиксировать. Для bash ci/build.sh файлу не требуется бит исполнения, тогда как ./ci/build.sh проверяет права и читает shebang. Если этап сборки Xcode, скрипт планировщика задач и локальный терминал используют разные способы запуска, может возникнуть ложное впечатление, что ошибка появляется только в CI.

Цель исправления — не добиться случайного успеха одного задания, а явно определить в репозитории, какой интерпретатор должен выполнять скрипт, разрешён ли его прямой запуск и какие переводы строк обязательны.

Исправьте бит исполнения в индексе Git

Входные скрипты, запускаемые напрямую, должны быть записаны как исполняемые. Фрагменты, подключаемые через source или явно передаваемые интерпретатору, обычно сохраняют режим 100644. Сначала составьте список точек входа, чтобы не делать исполняемыми все файлы .sh.

git update-index --chmod=+x ci/build.sh
git update-index --chmod=+x ci/test.sh
git diff --summary
git ls-files -s -- ci/build.sh ci/test.sh

Если git diff --summary не показывает изменения режима, проверьте, не отключено ли локальное отслеживание прав:

git config --get core.fileMode
git config core.fileMode true

core.fileMode влияет только на то, как текущее рабочее дерево обнаруживает изменения. Вместе с коммитами распространяется режим, записанный в индексе Git. Не используйте chmod -R +x . при запуске runner-а как постоянное решение: эта команда скрывает ошибки репозитория и может без необходимости сделать исполняемыми конфигурационные файлы, материалы с секретами или обычные файлы данных.

В ci/entrypoints.txt можно построчно перечислить скрипты, которые разрешено запускать напрямую. Тогда при проверке достаточно обрабатывать только этот список. При добавлении новой точки входа необходимо одновременно обновить файл регистрации, а во время ревью будет видно, почему изменилась граница прав.

Зафиксируйте LF и осторожно нормализуйте существующие файлы

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

*.sh       text eol=lf
*.command  text eol=lf
*.zsh      text eol=lf
*.xcconfig text eol=lf
*.pbxproj  text eol=lf
*.png      binary
*.zip      binary

После добавления правил уже закоммиченные файлы не будут переписаны автоматически. Выполните следующие команды в чистой ветке:

git add --renormalize .
git status --short
git diff --check
git diff --cached --stat

Сначала проверьте различия, а затем сохраните результат нормализации отдельным коммитом, не смешивая его с функциональными изменениями. Если одновременно изменилось много файлов проекта, выборочно убедитесь, что затронуты только переводы строк. Архивы, генераторы кода и внешние скрипты всё ещё могут повторно добавлять CRLF, поэтому .gitattributes не заменяет блокирующую проверку.

Результат проверки Частая причина Правильное действие
100644 при прямом запуске Права не записаны в индексе Исправить с помощью git update-index --chmod=+x
В конце строки отображается ^M Файл использует CRLF Нормализовать в LF и закоммитить
bad interpreter В shebang присутствует CR или указан недействительный путь Исправить первую строку и проверить наличие интерпретатора
Локально работает, в CI завершается ошибкой Различаются способ запуска или Shell Вывести команду запуска и явно выбрать Shell

Превратите различия интерпретаторов в явный контракт

Интерактивный Shell по умолчанию в macOS и Shell, фактически используемый в CI, могут различаться. Если скрипт использует массивы Bash, [[ ]] или подстановку процессов, его нельзя неявно выполнять через sh. Зависимость от синтаксиса zsh также должна быть объявлена напрямую.

#!/usr/bin/env bash
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"

Не рассчитывайте, что интерактивные конфигурационные файлы предоставят PATH, псевдонимы или переменные окружения. Точка входа CI должна самостоятельно задавать минимальный PATH и проверять наличие инструментов с помощью command -v xcodebuild, command -v ruby и аналогичных команд. Необходимые переменные окружения должны передаваться конфигурацией задания. Скрипт должен проверять только их имена, не выводя конфиденциальные значения.

Также не запускайте через sh script.sh файл с Bash в shebang: явный вызов sh игнорирует shebang. Задания на узлах DebugMac тоже должны опираться на объявления в репозитории, а не определять среду выполнения по Shell, который случайно оказался доступен в конкретном сеансе.

Настройте двойной контроль перед коммитом и в CI

Локальный hook сокращает время обратной связи, но не может быть единственной защитой, поскольку при обычном клонировании hooks не включаются автоматически. CI должен выполнять лёгкие проверки до установки зависимостей и запуска сборки Xcode.

#!/usr/bin/env bash
set -euo pipefail

while IFS= read -r script; do
  [[ -z "$script" ]] && continue
  mode="$(git ls-files -s -- "$script" | awk '{print $1}')"
  [[ "$mode" == "100755" ]] || {
    printf 'entrypoint is not executable: %s\n' "$script" >&2
    exit 1
  }
done < ci/entrypoints.txt

if git grep -Il $'\r' -- '*.sh' '*.zsh' '*.command' > /tmp/crlf-files; then
  printf 'CRLF detected:\n' >&2
  cat /tmp/crlf-files >&2
  exit 1
fi

Контроль также должен подтверждать, что пути из файла регистрации действительно существуют, и отклонять незакоммиченные изменения нормализации. Итоговую проверку можно провести в четыре этапа: выполнить чистое извлечение репозитория, напрямую запустить входные скрипты, убедиться, что git status --porcelain ничего не выводит, и выполнить минимальную сборку в неинтерактивном сеансе. Тогда проблемы с правами и переводами строк обнаружатся до дорогостоящей загрузки зависимостей и начала компиляции, а работоспособность процесса не будет зависеть от оперативных изменений runner-а.

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

Нужен ли исполняемый бит, если скрипт запускается командой bash?

Для bash script.sh он не обязателен, но прямой запуск и некоторые этапы сборки требуют его. Точки входа следует явно хранить в Git с режимом 100755.

Исправит ли .gitattributes уже сохранённые CRLF автоматически?

Нет. Выполните git add --renormalize ., проверьте изменения и зафиксируйте их. Затем оставьте проверку CRLF в CI для импортируемых и генерируемых файлов.

Debug M4

Запустите следующую сборку на выделенном физическом узле Apple Silicon

Выберите срок аренды и регион узла, проверьте Mac Mini M4, 16 ГБ RAM, SSD 256 ГБ и сумму к оплате в USD, затем оформите заказ.

Арендовать облачный Mac