Привет, Хабр!

Если вы работаете с 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, работать с несколькими окружениями из одного конфига.


Что умеет glenv

Коротко о возможностях:

  • 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

Или скачать бинарник под свою платформу со страницы релизов.


Основной сценарий использования

1. Смотрим что изменится

Сначала всегда стоит запустить 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

2. Автоклассификация переменных

GitLab требует чтобы masked-переменные были однострочными, минимум 8 символов, без спецсимволов. Проставлять это руками — боль. glenv делает это автоматически:

Свойство

Условие

masked

Ключ содержит _TOKEN, SECRET, PASSWORD, API_KEY, DSN — и значение однострочное, ≥8 символов

protected

Окружение production И ключ подходит под паттерн секрета

file

Ключ содержит PRIVATE_KEY, _CERT, _PEM — или значение содержит -----BEGIN

Переменные с плейсхолдерами (your_api_key_here, CHANGE_ME, REPLACE_WITH_) автоматически пропускаются — в GitLab не попадут.

Паттерны настраиваются через конфиг. Например, если у вас есть MAX_TOKENS (лимит запросов), его не нужно маскировать:

classify:
  masked_exclude:
    - "MAX_TOKENS"
    - "TIMEOUT"
    - "PORT"

3. Несколько окружений

Создаём .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 всё равно попробует отработать, а в конце будет общий отчёт.

4. Export

Выгрузить текущие переменные из GitLab в файл:

glenv export -e production -o .env.production.backup

File-type переменные (сертификаты, PEM-ключи) пропускаются и заменяются комментарием # KEY (file type, skipped) — в .env формат они всё равно не влезут корректно.

5. Использование в GitLab CI

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}

Немного про устройство изнутри

Rate limiting

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

Парсинг .env

Поддерживаются:

KEY=value
QUOTED="value with spaces"
SINGLE_QUOTED='value'

# Многострочные значения
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----"

Переменные с интерполяцией (${OTHER_VAR}/path) пропускаются — они скорее всего не имеют смысла как GitLab-переменные.

Diff engine

Перед применением изменений движок делает:

  1. Парсит локальный .env файл

  2. Получает текущие переменные из GitLab (с пагинацией)

  3. Сравнивает по ключу и environment scope

  4. Формирует список изменений: CREATE / UPDATE / DELETE / UNCHANGED / SKIPPED

  5. При 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 или нашли другое решение? Расскажите в комментариях.

Комментарии (6)


  1. paramtamtam
    25.02.2026 10:11

    Разрешите вопрос - а почему не мигрировали с env в сторону vault/doppler/etc? (может я был не внимателен и не заметил ответа в теле поста...)


    1. ZiBoX Автор
      25.02.2026 10:11

      Хороший вопрос. Если коротко — glenv не конкурент Vault/Doppler, он работает на другом уровне.

      GitLab CI/CD переменные — это уже встроенный менеджер секретов внутри GitLab: masked, protected, environment scope, audit log. Если вы и так на
      GitLab — зачем поднимать и оплачивать дополнительную инфраструктуру?

      glenv решает ровно одну вещь: неудобный интерфейс управления этими переменными. Перенести 80 переменных из .env файла в GitLab через веб-UI —
      мучение. glenv делает это одной командой с diff-preview.

      На мой взгляд Vault/Doppler имеют смысл когда:

      • Нужен мультиплатформенный доступ к секретам (не только GitLab, но и K8s, AWS, etc.)

      • Нужна динамическая ротация, fine-grained ACL, аудит на уровне отдельного секрета

      • Команда работает с несколькими инструментами за пределами GitLab-экосистемы

      Если же стек — это GitLab + GitLab CI, то встроенные переменные отлично справляются, а glenv просто убирает боль их обслуживания.


      1. paramtamtam
        25.02.2026 10:11

        Та я к тому, что так и нак нужен секрет для доступа к api - в вашем случае от gitlab, с случае с vault/doppler - он него. Вот только последние как раз спроектированы для работы с секретами, а gitlab env он даже называется именно env, и не очень хорош для работы с чувствительными данными, как будто в вашем случае он несколько не по прямому назначению используется что-ли (хотя и сам так и делал, и делаю в мелких проектах, но постоянно грызет совесть за это).

        Так же не покидает ощущение что ваш комментарий сгенерирован ии - длинные дефисы, нет разговорных оборотов, слишком "вылизанный" текст. Я прав, или мне это показалось?


        1. ZiBoX Автор
          25.02.2026 10:11

          Да, GitLab Variables это не Vault. Но masked + protected + audit log для большинства задач хватает. Vault нужен когда динамическая ротация, lease, интеграция с k8s и тд. Для "закинуть переменные и иногда обновлять" - оверкилл.

          По поводу ИИ - да, помогал структурировать, каюсь. Но суть от этого не меняется) Но если для вас это принципиально, буду писать с ошибками и сумбурно )


  1. 1024rk
    25.02.2026 10:11

    Эм, простите, но зачем 80 переменных в env-файле тащить в гитлаб полностью? У меня как правило (там, где деплой на один сервер) всего две переменных - токен docker registry и весь енв самого проекта в одной переменной типа file. CI читает эту переменную, source'ит из неё всё в окружение, работает прекрасно и довольно удобно.


    1. ZiBoX Автор
      25.02.2026 10:11

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

      А тут GitLab из коробки дает protected/masked на каждую переменную отдельно. Плюс практичные штуки: надо глянуть какой сейчас DATABASE_URL на проде - открыл UI и видишь. CI упал - поправил один ключ за 5 секунд, не редактируя весь блоб. glenv просто мост между локальным .env и GitLab, без лишней инфраструктуры.