В Bazel есть любопытная фича, позволяющая добавить данные, которые не инвалидируют кэш сборки. Например, это бывает полезно, чтобы добавить в исполняемый файл информацию о том, когда он был собран и из какой ревизии. Если для времени и номера ревизии использовать stamping, то, когда собранный файл уже есть в кэше, он пересобираться не будет. То есть мы получаем следующее: любое значимое изменение соберет файл заново; внутри файла будет информация, достаточная для того, чтобы заниматься его отладкой (из указанной ревизии можно собрать эквивалентный файл); при этом не будет происходить лишней пересборки на каждый коммит из-за не влияющих на него изменений, так как номер ревизии не учитывается при поиске в кэше. В GoLang, к примеру, начиная с версии 1.18, можно получить идентификатор ревизии, от которой был собран файл, через debug.ReadBuildInfo. Для объявления переменных stamping-а нужно завести исполняемый файл, который запишет в стандартный вывод пары ключ-значение через пробел по одной паре на строку. Этот файл будет выполняться в корне рабочего пространства. Например: Пользовательские переменные с префиксом STABLE_ будут участвовать в ключе кэширования. Участвующие в ключе кэширования переменные попадут в файл bazel-out/stable-status.txt , а не участвующие попадут в файл bazel-out/volatile-status.txt. Для того, чтобы Bazel знал, где находится файл, собирающий пользовательские переменные, файл нужно ему передать через ключ --workspace_status_command= (https://bazel.build/reference/command-line-reference#flag--workspace_status_command). Любопытно, но при написании этого поста, я обнаружил, что скрипт размещенный в корне рабочего пространства, не работает. У многих правил stamping работает только при сборке с флагом --stamp. Полный пример доступен на Github. Для того, чтобы можно было работать с GoLang в Bazel, создадим три файла. Пустой файл BUILD. Файл WORKSPACE (этот фрагмент взят здесь): Файл go.mod для того, чтобы можно было сравнить поведение с go build: Создадим простой скрипт, который положит в переменную GIT_COMMIT текущую ревизию кода example/stamping.sh: И, чтобы не передавать имя этого файла при каждом запуске bazel, добавим его в .bazelrc: Добавил программу для вывода полученных на этапе сборки значений example/main.go: Эта программа делает следующее: выводит содержимое debug.ReadBuildInfo как есть; выводит значение vcs.revision и vcs.time, которые передаются средствами go build, если он используется; выводит значение переменных gitCommit и buildTimestamp, которые в коде нигде не задаются. Если эту программу запустить через go build . && ./example или, начиная с Go 1.20, через go run -buildvcs=true ., то мы увидим примерно следующее: То есть, в debug.ReadBuildInfo() появилась информация из текущей рабочей копии Git. gitCommit и buildTimestamp ожидаемо пусты. Добавим правило сборки .go-файла в example/BUILD: В этом правиле примечателен только параметр x_defs: в переменную gitCommit задаётся значение из stamping-переменной GIT_COMMIT; в переменную buildTimestamp задаётся значение из stamping-переменной BUILD_TIMESTAMP. В данном примере x_defs объявлен непосредственно на go_binary, но его так же можно использовать в go_library и go_test. Данные для debug.ReadBuildInfo() Bazel сам не заполняет, но, если очень хочется, то их можно задать через runtime.modinfo. Правда, есть ряд особенностей: версия Go живёт за пределами modinfo; в самом значении runtime.modinfo по 16 байт с краёв отводятся на различные служебные значения, позволяющие зачитать эти данные снаружи через buildinfo.Read (https://pkg.go.dev/debug/buildinfo#Read). В результате при запуске этой программы мы получим: При этом, что важно – если сделать коммит, который не затрагивает данную программу, то пересборки исполняемого файла не произойдёт. Полный пример доступен на Github. Для примера создадим пустой файл WORKSPACE (в этом случае у нас нет внешних зависимостей). Добавим генерацию переменных в файл example/stamping.sh: И добавим правило сборки, которое будет реализовано чуть ниже BUILD: Это правило будет подставлять значения stamping-переменных в шаблон hello_template.txt: Собственно, вся работа будет выполняться довольно простым скриптом на Python example/stamping.py: Этот скрипт: получает через аргументы командной строки файл шаблона, файлы со stamping-переменными и имя выходного файла; зачитывает stamping-переменные в dict; заменяет в шаблоне переменные через регулярное выражение; записывает результат в файл. Никаких python-библиотек за пределами стандартного Python SDK он не использует. Для реализации правила stamping понадобится объявить дополнительные цели в example/BUILD: Они понадобятся внутри реализации правила на Starlark для того, чтобы: //example:stamping – вызвать ранее созданный скрипт stamping.py; //example:stamp_detect – получить значение стандартного bazel-флага --stamp (https://bazel.build/reference/command-line-reference#flag--stamp). Само правило на Starlark example/stamping.bzl: На что хотелось бы обратить внимание: все stamping-переменные разворачиваются уже на этапе выполнения правила; файлы volatile-status.txt и stable-status.txt, явно фигурируют как выходные данные правила; для обработки флага --stamp, нужно сделать дополнительные приседания с config_setting. Для проверки можно выполнить команды: По-умолчанию stamping всегда предоставляет ряд переменных: BUILD_EMBED_LABEL (stable) – значение флага --embed_label=...; BUILD_HOST (stable) – имя хоста, на котором инициировали сборку; BUILD_USER (stable) – имя пользователя, который инициировал сборку; BUILD_TIMESTAMP (volatile) – unix time времени начала сборки. При этом, важно заметить, что на ферме внутри скрипта часто имеет смысл переопределить поля BUILD_HOST и BUILD_USER, иначе смена хоста и пользователя будет провоцировать пересборку шагов, которые использую stamping. Важная проблема stamping – он ломается при использовании внешнего кэша. У Bazel есть несколько кэшей: кэш графа целей в памяти Bazel-демона; локальный кэш операций ($(bazel info output_base)/action_cache); внешний кэш опреаций (--disk_cache, --remote_cache, сборочная ферма и т.п.). При этом у локального и внешнего кэша разный ключ кэширования. В случае с внешним кэшем в ключе кэширования участвуют все входные данные, которые используются для выполнения соответствующего действия, в том числе переменные окружения, командная строка, входные файлы (де-факто ключ кэширования – это хэш от protobuf-описания шага сборки). Файл bazel-out/volatile-status.txt так же является входным файлом и его содержимое начинает влиять на ключ кэширования. В результате при использования внешнего кэша и stamping-а, мы всегда получаем новый ключ кэширования: каждое действие сборки, которое использует stamping, всегда идёт мимо кэша. Крайне неприятно то, что при локальных экспериментах можно получать попадание в локальный кэш и создаётся впечатление, что всё работает так, как нужно. А при сборке на ферме поведение резко меняется на постоянную пересборку. Убедиться в наличии или отсутствии проблемы со stamping и remote cache можно достаточно простым способом: Собрать файл с включенным --disk_cache и --stamp. После этого все данные для сборки должны попасть в дисковый кэш.Например:bazel run --stamp --disk_cache=/tmp/bazel-disk-cache //example Собрать файл с включенным --disk_cache без --stamp. Это действие должно инвалидировать локальных кэш Bazel.Например:bazel run --disk_cache=/tmp/bazel-disk-cache //example Еще раз собрать файл с включенным --disk_cache и --stamp. Это действие должно вместо сборки взять ранее собранный файл из дискового кэша.Например:bazel run --stamp --disk_cache=/tmp/bazel-disk-cache //example Если после первого и третьего шага будет одинаковый результат – то проблемы с remote cache нет. К сожалению, на данный момент (сейчас актуальная версия Bazel 6.0.0) это не так, и третий шаг гарантированно пересобирает исполняемый файл. На эту тему в Bazel есть несколько репортов: https://github.com/bazelbuild/bazel/issues/10075 https://github.com/bazelbuild/bazel/issues/16231 Но, к сожалению, корректное решение требует внесения правок во всю цепочку сборки: надо расширить remote execution protocol, добавив туда возможность передавать данные, которые не должны влиять на ключ кэша действия (сейчас ключ кэша – хэш от самого описания задачи для удалённой сборки); надо добавить поддержку нового протокола в bazel; надо добавить поддержку нового протокола на ферме. В частности, как я понимаю, из-за большого количества действующих лиц, эта проблема не решается на протяжении уже двух лет. В качестве обходного варианта можно вынести логику шага, использующего stamping, во внешний сервис. В таком случае действие должно получить примерно следующий вид: на вход получаем volatile-status.txt и входные файлы, которые необходимы и достаточны для следующего шага; считаем хэш от входных файлов для следующего шага и получаем какой-то идентификатор (назовём его hash_id); отправляем во внешний сервис volatile-status.txt и hash_id, а этот сервис возвращает volatile-status.txt, который был отправлен в первый раз для этого hash_id, назовём его first-volatile-status.txt; выполняем следующий шаг с first-volatile-status.txt вместо volatile-status.txt. У этого механизма есть очевидная проблема: он требует модификации всех правил, которые используют stamping. Если какое-то из них забыть поправить или ошибиться в реализации, то корректность работы будет нарушена. Еще один из вариантов обхода этой проблемы: подштопать bazel, чтобы он при подсчете кэша не учитывал volatile-данные для stamping-а. К сожалению, в таком случае выполнять эти действия на ферме будет нельзя, но ни что не мешает их выполнять локально. Заплатку с исправлением Bazel можно взять здесь: https://github.com/bazelbuild/bazel/pull/16240 Этот подход то же не без недостатка: у bazel-клиента должны быть права заливать данные в кэш сборки. Тем не менее в нашем случае этот подход работает без особых нареканий. В следующем посте про Bazel мы расскажем о том, как мы приводили stacktrace собранных на CI исполняемых файлов к удобному для работы виду.  Программист