glenv: синхронизируем .env файлы с GitLab CI/CD переменными без боли
- четверг, 26 февраля 2026 г. в 00:00:19
Привет, Хабр!
Если вы работаете с GitLab и у вас больше одного окружения — вы наверняка знаете этот ритуал: открываешь Settings → CI/CD → Variables, начинаешь вбивать переменные вручную, на пятой ошибаешься, на двадцатой теряешь счёт, на пятидесятой начинаешь сочувствовать тем, кто хранит секреты прямо в коде.
Я написал glenv — CLI-инструмент на Go, который синхронизирует .env файлы с GitLab CI/CD переменными через API. Под катом — история о том, почему существующих решений не хватило, как это устроено внутри и несколько примеров использования.
Всё началось с простой задачи: нужно было завести ~80 переменных для нового production-окружения. Существующие переменные жили в .env.production файле, который использовался локально. Оставалось только перенести их в GitLab.
Первая попытка — веб-интерфейс. Медленно, муторно, после двадцатой переменной начинаешь делать опечатки.
Вторая попытка — bash-скрипт на curl:
while IFS='=' read -r key value; do [[ "$key" =~ ^#.*$ ]] && continue curl -s -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/variables" \ --header "PRIVATE-TOKEN: $TOKEN" \ --form "key=$key" \ --form "value=$value" done < .env.production
Работало, пока не перестало. Проблемы обнаруживались по одной: нет обработки masked/protected флагов, нет rate limiting (привет, 429), нет retry при ошибках, нет возможности посмотреть что изменится перед применением. И главное — никакого diff: запустил скрипт, получил непонятный результат, иди разбирайся.
После третьего "а что сейчас в GitLab, то же что в файле?" я решил написать нормальный инструмент.
Перед тем как писать своё, поискал готовые решения:
glab variable (официальный CLI) — работает с одной переменной за раз. glab variable set KEY value — это не bulk операции, это тот же ручной процесс, только в терминале.
nodejs-glabenv — Node.js, базовый import/export без классификации и rate limiting. Требует Node в окружении.
gitlab-dotenv — Python, аналогично. Работает, но нет diff, нет умной классификации переменных.
Bash + curl — уже описал выше. Хрупко и без обратной связи.
Ни один не делал того, что реально нужно в production: показать diff перед применением, автоматически проставить masked/protected флаги, нормально обработать ошибки API, работать с несколькими окружениями из одного конфига.
Коротко о возможностях:
Bulk sync — загружает весь .env файл в GitLab за одну команду
Diff перед применением — показывает что создастся, обновится или удалится, ничего не трогая
Автоклассификация — сам определяет masked, protected и file-тип по имени ключа и значению
Rate limiting — token bucket, общий на всех воркеров; корректно обрабатывает 429 с Retry-After
Multi-environment — production, staging и любые кастомные окружения из одного YAML конфига
Export — скачивает текущие переменные из GitLab в формат .env
Dry-run — показывает что произошло бы, без единого API-вызова
Self-hosted — работает с любым инстансом GitLab, настраиваемые лимиты
Написан на Go: статический бинарник, нет зависимостей рантайма, работает на Linux, macOS, Windows.
# macOS/Linux через Homebrew brew install ohmylock/tools/glenv # Через go install go install github.com/ohmylock/glenv/cmd/glenv@latest
Или скачать бинарник под свою платформу со страницы релизов.
Сначала всегда стоит запустить diff:
export GITLAB_TOKEN="glpat-xxxxxxxxxxxx" export GITLAB_PROJECT_ID="12345678" glenv diff -f .env.production -e production
Вывод:
+ DB_HOST=postgres.internal + DB_PORT=5432 ~ API_KEY: *** → *** [masked] - OLD_DEPRECATED_VAR = LOG_LEVEL
+ — создастся, ~ — обновится, - — удалится, = — не изменится. Masked-значения показываются как ***.
Только убедившись что всё правильно, применяем:
glenv sync -f .env.production -e production
GitLab требует чтобы masked-переменные были однострочными, минимум 8 символов, без спецсимволов. Проставлять это руками — боль. glenv делает это автоматически:
Свойство | Условие |
|---|---|
masked | Ключ содержит |
protected | Окружение |
file | Ключ содержит |
Переменные с плейсхолдерами (your_api_key_here, CHANGE_ME, REPLACE_WITH_) автоматически пропускаются — в GitLab не попадут.
Паттерны настраиваются через конфиг. Например, если у вас есть MAX_TOKENS (лимит запросов), его не нужно маскировать:
classify: masked_exclude: - "MAX_TOKENS" - "TIMEOUT" - "PORT"
Создаём .glenv.yml в корне проекта:
gitlab: token: ${GITLAB_TOKEN} # поддерживается подстановка env-переменных project_id: "12345678" rate_limit: requests_per_second: 10 max_concurrent: 5 environments: staging: file: deploy/.env.staging production: file: deploy/.env.production
Теперь можно синхронизировать все окружения одной командой:
glenv sync --all
Окружения обрабатываются последовательно (в алфавитном порядке), ошибки агрегируются — если staging завалился, production всё равно попробует отработать, а в конце будет общий отчёт.
Выгрузить текущие переменные из GitLab в файл:
glenv export -e production -o .env.production.backup
File-type переменные (сертификаты, PEM-ключи) пропускаются и заменяются комментарием # KEY (file type, skipped) — в .env формат они всё равно не влезут корректно.
sync-variables: image: golang:1.23-alpine script: - go install github.com/ohmylock/glenv/cmd/glenv@latest - glenv sync -f deploy/.env.${CI_ENVIRONMENT_NAME} -e ${CI_ENVIRONMENT_NAME} variables: GITLAB_TOKEN: ${DEPLOY_TOKEN} GITLAB_PROJECT_ID: ${CI_PROJECT_ID}
GitLab.com пропускает ~2000 запросов в минуту (~33/сек). При 5 воркерах без ограничений легко улететь в 429.
glenv использует token bucket rate limiter, который делится между всеми воркерами. По умолчанию — 10 запросов в секунду. При 429-ответе читается заголовок Retry-After, инструмент ждёт указанное время, затем повторяет попытку с экспоненциальным backoff + jitter. Максимум 3 ретрая на операцию.
Для self-hosted инстансов лимиты настраиваются:
glenv sync -f .env -e production --workers 10 --rate-limit 50
Поддерживаются:
KEY=value QUOTED="value with spaces" SINGLE_QUOTED='value' # Многострочные значения PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEA... -----END RSA PRIVATE KEY-----"
Переменные с интерполяцией (${OTHER_VAR}/path) пропускаются — они скорее всего не имеют смысла как GitLab-переменные.
Перед применением изменений движок делает:
Парсит локальный .env файл
Получает текущие переменные из GitLab (с пагинацией)
Сравнивает по ключу и environment scope
Формирует список изменений: CREATE / UPDATE / DELETE / UNCHANGED / SKIPPED
При sync — раздаёт изменения по воркер-пулу с rate limiting
Честно о том, чего пока нет:
Только project-level переменные. Group-level — в планах, но пока не реализовано
Один проект за раз. Несколько проектов — несколько конфигов и несколько вызовов
Нет glenv import для копирования переменных между проектами
Интеграционные тесты требуют реального GitLab-инстанса и GITLAB_TEST_PROJECT_ID
Group-level переменные — управление переменными на уровне группы
glenv import — копирование переменных между проектами или инстансами
Watch mode — отслеживать изменения в .env файле и синхронизировать автоматически
Pre-built binary в GitHub Actions — чтобы не делать go install в каждом pipeline
glenv решает конкретную задачу: синхронизировать .env файлы с GitLab CI/CD переменными без ручной работы и без риска случайно что-то сломать. Diff перед применением, автоматическая классификация masked/protected, нормальная обработка rate limit — то, чего не хватало в существующих решениях.
Исходники: github.com/ohmylock/glenv. Буду рад вопросам, issues и PR-ам.
А как вы управляете GitLab CI/CD переменными в своих проектах? Пишете скрипты, пользуетесь официальным CLI или нашли другое решение? Расскажите в комментариях.