Перейти к содержимому
GitRiverGitRiver
EN
Навигация

GitRiver CI — Руководство пользователя

Конвейеры сборки: файл описания, задания и шаги, зависимости, переменные и секреты, кеш, артефакты, исполнители

Рабочий процесс GitRiver CI описывается YAML-файлом в репозитории, и основа его — скрипты run:. Готовые действия (uses:) поддержаны частично, а их внешние источники администратор установки ограничивает списком разрешённых доменов — см. «uses — готовые действия».


Быстрый старт

Создайте файл .gitriver/workflows/ci.yml в корне репозитория:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    steps:
      - run: echo "Hello, GitRiver CI!"

При отправке в main или открытии запроса на слияние запустится рабочий процесс с одним заданием test.


Структура файла рабочего процесса

name — имя рабочего процесса

name: Build and Deploy

Отображается в интерфейсе. Если не указано — используется имя файла.

on — триггеры

Определяют, когда запускается рабочий процесс.

push — отправка

on:
  push:
    branches: [main, develop, 'release/**']
    branches-ignore: ['feature/wip-*']
    tags: ['v*']
    tags-ignore: ['v*-rc*']
    paths: ['src/**', 'Cargo.toml']
    paths-ignore: ['docs/**', '*.md']
  • branches / branches-ignore — маски веток
  • tags / tags-ignore — маски тегов
  • paths / paths-ignore — рабочий процесс запускается только если изменены файлы по маске
  • branches и branches-ignore взаимоисключающие (нельзя оба)
  • Если указаны paths, рабочий процесс пропускается, когда ни один файл не совпал

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

Маски путей

Правило одно на все маски CI — branches, tags, paths/paths-ignore в on: и paths в artifacts::

  • маска сопоставляется с путём целиком (для artifacts: — от рабочей копии задания), а не с именем файла: *.so — это lib.so в корне, но не target/release/lib.so;
  • * — любая последовательность символов внутри одного сегмента, ? — ровно один символ; ни тот, ни другой не пересекают /;
  • ** — отдельный сегмент маски: ноль или больше сегментов пути. src/** — и сам src, и всё, что под ним; **/*.rs — и main.rs, и src/main.rs; a/**/b — и a/b, и a/x/y/b;
  • маска без * и ? — это точный путь. Классы символов ([abc]) не поддерживаются и сравниваются как обычные символы;
  • в branches и tags маска из одной звёздочки ('*') читается как «любое значение», включая имена со слэшем: branches: ['*'] — все ветки.

pull_request — запрос на слияние

on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches: [main]
    paths: ['src/**']
  • types — какие события запроса на слияние запускают рабочий процесс (по умолчанию: opened, synchronize, reopened)
  • branches — целевая ветка запроса (куда сливают)
  • paths — фильтр по изменённым файлам

Сервер порождает три действия: opened — при создании запроса, reopened — при повторном открытии, synchronize — при каждой отправке в исходную ветку, пока запрос открыт. Правка заголовка и описания прогона не запускает; сменить целевую ветку у открытого запроса нельзя.

paths считается по разнице всего запроса — от точки ветвления до его вершины, а не по одной отправке: результат не зависит от того, каким по счёту коммитом внесено изменение.

schedule — расписание cron

Точность расписания. Cron-выражения проверяются раз в минуту, поэтому фактический запуск может отстать от заданного времени на несколько десятков секунд. Повторное совпадение по одной и той же ветке в пределах 120 секунд не запускает вторую сборку — рабочий процесс с несколькими близкими cron-правилами не задваивает запуск.

on:
  schedule:
    - cron: '0 2 * * *'      # Каждый день в 02:00 UTC
    - cron: '0 */6 * * *'    # Каждые 6 часов
  • Стандартный cron из 5 полей (минута час день месяц день_недели)
  • Выполняется на ветке по умолчанию
  • Минимальный интервал: 15 минут

У расписания есть автор — тот, чьей отправкой cron попал в ветку по умолчанию. Перед каждым запуском проверяется, что этот человек по-прежнему существует, не отключён внешним управлением (SCIM) и сохраняет право ci:trigger в репозитории. Иначе расписание приостанавливается: сборка не идёт, а причина видна на вкладке CI/CD (GET /api/v1/repos/{owner}/{name}/ci/schedules). Так уход сотрудника не оставляет после себя работающего механизма развёртывания: и отправка, и ручной запуск такому человеку тоже отказывают. В архивном репозитории расписание приостанавливается по той же причине — запись в него запрещена.

Приостановка снимается сама: право проверяется заново на каждом сроке запуска, и восстановленный доступ возвращает расписание к работе без вмешательства. Если учётной записи автора больше нет, расписание заводится заново — изменением cron-выражения в рабочем процессе: новая пара (файл, cron) получает автором того, кто её внёс.

Если отправку, внёсшую расписание, сделал не человек (токен задания CI или токен развёртывания), а также у расписаний, для которых автор не записан, автором считается владелец репозитория. Такое расписание встаёт, когда владельца отключают внешним управлением, и возобновляется, как только учётную запись включат обратно или репозиторий передадут действующему владельцу.

Ручной запуск (workflow_dispatch)

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Окружение для развёртывания'
        required: true
        type: choice
        options: [staging, production]
        default: staging
      debug:
        description: 'Включить отладку'
        type: boolean
        default: false
      version:
        description: 'Версия для развёртывания'
        type: string
      replicas:
        description: 'Количество реплик'
        type: number
        default: 3

Типы параметров запуска (inputs): string, boolean, choice, number.

Значения доступны в переменных: $INPUT_ENVIRONMENT, $INPUT_DEBUG и т.д.

Комбинирование триггеров

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: '0 2 * * *'
  workflow_dispatch:

Рабочий процесс запустится при любом из перечисленных событий.


env — глобальные переменные окружения

env:
  RUST_LOG: info
  CARGO_TERM_COLOR: always
  REGISTRY: registry.example.com

Доступны всем заданиям и шагам. Переопределяются на уровне задания и шага.


concurrency — группа одновременности

concurrency:
  group: deploy-$CI_COMMIT_BRANCH
  cancel-in-progress: true
  • group — имя группы (поддерживает подстановку $VAR)
  • cancel-in-progress: true — автоматически отменяет предыдущий запуск в той же группе

Группа обещает одно: в ней одновременно идёт не больше одного конвейера. cancel-in-progress выбирает, чем это обещание держится:

cancel-in-progress Что происходит с новым запуском Что происходит с идущим
true идёт сразу отменяется
false (по умолчанию) ждёт в очереди группы доходит до конца

Ждущий конвейер заведён и виден в списке — с отметкой «в очереди группы», — но исполнителей не занимает. Как только место освободится, он начнётся сам; отдельно запускать его не нужно.

Ждать может только последний: если за время ожидания пришла ещё одна отправка, предыдущий ожидающий отменяется. Так на активной ветке не копится ряд заведомо устаревших сборок.

Пока конвейер ждёт места:

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

Группа действует в пределах репозитория: одноимённая группа в другом репозитории — другая группа, и запуски двух проектов друг друга не отменяют и друг друга не ждут.

Прерываемость по ref работает как обычно и при объявленной группе: задание с interruptible: true прервётся новой отправкой.

Пример: при отправке в main новое развёртывание отменяет старое, ещё не завершённое.

Краткая форма:

concurrency: deploy-$CI_COMMIT_BRANCH

Эквивалентна group: ..., cancel-in-progress: false — то есть с ожиданием в очереди, а не с отменой идущего.


jobs — задания

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

jobs:
  build:
    name: Сборка проекта
    steps:
      - run: cargo build --release

  test:
    name: Тесты
    needs: [build]
    steps:
      - run: cargo test

needs — зависимости между заданиями

jobs:
  build:
    steps:
      - run: cargo build

  unit-tests:
    needs: [build]
    steps:
      - run: cargo test --lib

  integration-tests:
    needs: [build]
    steps:
      - run: cargo test --test '*'

  deploy:
    needs: [unit-tests, integration-tests]
    steps:
      - run: ./deploy.sh
  • Задания из needs должны завершиться успешно до запуска
  • Без needs задание запускается сразу (параллельно с другими)
  • Циклические зависимости — ошибка проверки файла

Зависимость можно записать и словарём:

deploy:
  needs:
    - job: tests
    - job: lint
      optional: true

optional: true означает, что зависимость может не выполняться: если lint пропущен своим условием if:, deploy всё равно запустится — после tests. Провал необязательной зависимости останавливает зависимое задание так же, как провал обязательной. Чтобы её провал не мешал зависимым, объявите у неё allow-failure: true.

if — условие запуска задания

jobs:
  deploy:
    if: $CI_COMMIT_BRANCH == "main"
    steps:
      - run: ./deploy.sh

  notify-on-fail:
    if: failure()
    needs: [deploy]
    steps:
      - run: curl -X POST $SLACK_WEBHOOK

Поддерживаемые выражения:

Выражение Описание
$VAR == "value" Сравнение строк
$VAR != "value" Неравенство
$VAR =~ /pattern/ Совпадение с регулярным выражением
$VAR Истина, если переменная задана и не пуста
!expr Логическое отрицание
expr1 && expr2 Логическое И
expr1 || expr2 Логическое ИЛИ
(expr) Группировка
success() Все предыдущие задания успешны (по умолчанию)
failure() Хотя бы одно предыдущее задание провалилось
always() Всегда выполнять (даже при отмене)
cancelled() Конвейер отменён

Условие вычисляет сервер — до того, как задание попадёт исполнителю, и одинаково для встроенного исполнителя и внешнего исполнителя. Задание с ложным условием получает состояние «пропущено» и исполнителю не выдаётся вовсе, поэтому runs-on на действие if: не влияет: deploy с условием по ветке с другой ветки не развернётся ни на одном исполнителе.

Пропуск — не провал. Для зависящих от него заданий пропущенное задание значит «не выполнялось»:

  • задание с условием по умолчанию (success()) тоже пропускается, если зависимость не объявлена с optional: true;
  • failure() истинно, только если зависимость провалилась. В примере выше notify-on-fail на ветке, отличной от main, где deploy пропущен, не выполняется: провала не было;
  • отменённая зависимость тоже останавливает задание с условием по умолчанию, но провалом не считается.

Чтобы зависимое задание выполнялось при любом исходе зависимостей, объявляйте его с if: always().

image — Docker-образ

jobs:
  build:
    image: rust:1.82-slim
    steps:
      - run: cargo build --release

Задание выполняется внутри указанного контейнера через docker run. Если образ не указан — скрипт выполняется напрямую в окружении исполнителя.

Скрипт выполняется в контейнере от root, поэтому созданные им файлы получают владельца root и права по umask образа. Сразу после скрипта исполнитель возвращает себе права на рабочую копию — тем же образом задания, коротким контейнером. Благодаря этому на образе с umask 0077 (например, redis:7) артефакты, отчёты и выходные данные задания собираются как обычно. Если вернуть права не удалось, в журнале задания появляется предупреждение. Исполнитель старее сервера этот шаг не выполняет — обновляйте исполнителей вместе с сервером.

services — сервисные контейнеры

jobs:
  test:
    image: rust:1.82
    services:
      - image: postgres:16
        alias: db
        env:
          POSTGRES_DB: test
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
      - image: redis:7
        alias: cache
    env:
      DATABASE_URL: postgres://test:test@db:5432/test
      REDIS_URL: redis://cache:6379
    steps:
      - run: cargo test
  • alias — имя узла для доступа к сервису из контейнера задания
  • env — переменные окружения для сервиса
  • Если alias не указан — используется имя образа до : (postgres, redis)
  • Сервисы запускаются в сети Docker и доступны по alias как по имени узла
  • Требуется image: — без образа Docker у задания сервисы не поднимаются; причина пишется в журнал задания, а не пропускается молча

Директива работает одинаково у встроенного исполнителя и у внешнего исполнителя: имя, по которому скрипт обращается к сервису, на обоих одно и то же; сеть и контейнеры поднимает тот, кто выполняет задание, на своём Docker. Исполнитель старее сервера поле services не разберёт и выполнит задание без сервисов — обновляйте исполнителей вместе с сервером.

timeout — срок задания

jobs:
  build:
    timeout: 30m     # 30 минут
    steps:
      - run: cargo build --release

Форматы: 30s, 10m, 1h, 1h30m. По умолчанию: 1h. Максимум: 6h.

Задание, не уложившееся в срок, снимается и числится провалившимся (не отменённым: отмена — решение человека). Последняя строка его журнала — «Задание снято по истечении срока», и повтор retry: when: [stuck_or_timeout_failure] рассчитан именно на этот исход. after_script при этом выполняется — у него свой срок (ci_after_script_timeout_secs).

allow-failure — допустимый провал

jobs:
  lint:
    allow-failure: true
    steps:
      - run: cargo clippy -- -D warnings

Если задание провалится, конвейер упавшим не считается. В интерфейсе задание отмечается предупреждением.

retry — автоповтор при провале

jobs:
  test:
    retry: 2           # Повторить до 2 раз при любой ошибке
    steps:
      - run: cargo test

Расширенная форма:

jobs:
  test:
    retry:
      max: 2
      when: [script_failure, stuck_or_timeout_failure]
    steps:
      - run: cargo test
  • max — максимальное количество повторов (0–2)
  • when — при каких ошибках повторять:
    • always — при любой ошибке (по умолчанию)
    • script_failure — при ненулевом коде выхода
    • stuck_or_timeout_failure — при истёкшем сроке

Отмена старше retry:: отменённое задание не повторяется ни при каком when — отмена — решение пользователя, а не сбой, который стоит переигрывать.

Между попытками в журнал записывается разделитель ──── Повтор 1/2 ────.

У задания с image: перед разделителем идёт строка Снятие контейнера прошлой попытки: <имя>: контейнер прерванной попытки снимается, чтобы следующая могла стартовать.

Политика действует и на заданиях внешнего исполнителя. Повтор там серверный: задание сбрасывается и ставится в очередь исполнителей заново — новую попытку вправе взять другой исполнитель. Невыполнение задания в срок исполнитель сообщает отдельно от провала скрипта, поэтому when: [stuck_or_timeout_failure] различает их и на нём. Исполнитель старее сервера сообщает любой провал как script_failure.

interruptible — прерываемость при новой отправке

jobs:
  test:
    interruptible: true
    steps:
      - run: cargo test

Если interruptible: true и приходит новая отправка в тот же ref, идущее задание автоматически отменяется в пользу нового конвейера. Полезно для тестов, бесполезно для развёртывания.

Отличие от concurrency:

  • concurrency — держит в группе (по имени группы, внутри своего репозитория) не больше одного идущего конвейера: с cancel-in-progress: true отменяя идущий, без него — ставя новый в очередь
  • interruptible — отменяет по ref (ветке/тегу), без явного указания группы

runs-on — метка исполнителя

Задание с runs-on, отличным от default, выполняет внешний исполнитель (gitriver-runner) с подходящими метками — см. «Клонирование репозитория внешним исполнителем». Если исполнители на установке не настроены вовсе, задание выполняется встроенным исполнителем.

Правило одно для всех способов запуска: первичный запуск конвейера, повтор задания и ручной запуск одинаково отдают задание с runs-on внешнему исполнителю — результат повтора равен результату первого запуска.

Если встроенный исполнитель выключен настройкой ci_local_executor_enabled (установка без Docker), метки не рассматриваются вовсе: внешним исполнителям уходит вся очередь, включая runs-on: default и задания без runs-on. Задание без меток вправе взять исполнитель с любыми метками, а runs-on: default — исполнитель, у которого метка default объявлена.

Запасного варианта «выполнить на сервере» в этом режиме нет: если исполнители не настроены или очередь недоступна, задание сразу завершается ошибкой, а причина пишется в его журнал («встроенный исполнитель отключён, нет доступного исполнителя»). Исполнитель, который зарегистрирован, но не в сети, — другой случай: задание ждёт его в очереди до ci_runner_max_wait_secs.

runs-on: default              # Встроенный исполнитель
runs-on: [linux, docker]      # Исполнитель с обеими метками

steps — шаги внутри задания

Каждый шаг — одна команда или блок команд.

steps:
  - name: Установка зависимостей
    run: |
      apt-get update
      apt-get install -y protobuf-compiler

  - name: Сборка
    run: cargo build --release 2>&1
    env:
      RUSTFLAGS: '-C target-cpu=native'

  - name: Уведомление
    if: failure()
    run: echo "Сборка провалилась"

  - name: Уборка
    if: always()
    run: rm -rf tmp/

Поля шага

Поле Описание
name Отображаемое имя (необязательно)
id Идентификатор шага (необязательно). Сослаться на него нечем: выходные данные шагов не собираются
run Команда или многострочный скрипт
uses Готовое действие вместо команды — см. «uses — готовые действия». С run в одном шаге несовместимо
with Входные параметры действия (только для uses) — приходят в него как INPUT_*
if Условие выполнения. if: always() — шаг выполняется при любом исходе предыдущих шагов, if: failure() — только после их провала; такие шаги идут в after_script — см. ниже
env Переменные окружения (только для этого шага)
timeout Срок шага (только для run)
continue-on-error true — шаг не ломает задание при ошибке
working-directory Рабочий каталог (только для run)
shell Оболочка шага run: sh (по умолчанию) или bash

Шаги не изолированы друг от друга. Они идут в одном сеансе командной оболочки: команда cd, экспортированная переменная или файл, которые создал один шаг, видны и следующим без явной передачи — как и рабочий каталог, они общие для всех шагов задания.

Исключение — шаг с shell: bash, timeout или working-directory: он выполняется отдельным процессом, и его cd и export следующим шагам не достаются (созданные им файлы — достаются).

Многострочные скрипты

- run: |
    echo "Шаг 1"
    echo "Шаг 2"
    if [ -f config.yml ]; then
      echo "Конфиг найден"
    fi

Выполняется как один скрипт оболочки sh с set -e (остановка при первой ошибке).

uses — готовые действия

Шаг с uses: выполняет готовое действие — код, который лежит не в этом файле рабочего процесса. С run: в одном шаге поле несовместимо: шаг — либо команда, либо действие.

steps:
  - name: Проверка формата
    uses: myorg/actions/fmt@v1
    with:
      check-only: 'true'
Четыре формы источника
uses: Откуда берётся действие @ref
docker://образ:тег Образ запускается напрямую, action.yml не нужен не нужен
./путь, ../путь Каталог рабочей копии — путь от текущего каталога шага, то есть от её корня, если шаги до этого не делали cd не нужен
owner/repo@ref, owner/repo/подпапка@ref Репозиторий этой установки обязателен
хост/owner/repo@ref, хост/owner/repo/подпапка@ref Внешний git-сервер, клон по HTTPS необязателен (умолчание main)

Формы различает точка в первом сегменте пути — это имя хоста. Точка в теге (myorg/action@v1.2.3) или в имени репозитория (myorg/my.action@v1) источник не меняет: такое действие берётся с этой же установки.

@ref — имя ветки или тега; SHA коммита в этом месте не принимается.

Хвост за owner/repo (и за хост/owner/repo) — подпапка действия внутри репозитория, а не часть его адреса: клонируется репозиторий, а action.yml читается из подпапки.

Действие с этой установки клонируется учётной записью задания (CI_JOB_TOKEN), поэтому приватный репозиторий доступен — в пределах прав задания (см. «Что может CI_JOB_TOKEN»). Внешний источник клонируется анонимно: приватный внешний репозиторий не склонируется.

Клон действия кешируется по паре «адрес + ref» в каталоге /tmp/gitriver-actions (переопределяется переменной GITRIVER_ACTION_CACHE). Кеш живёт там, где идёт скрипт задания: у задания без image: — на машине исполнителя, у задания с image: — в его контейнере, то есть ровно одно задание. Пока клон в кеше есть, он не обновляется — движение подвижной ссылки (@main) исполнитель не подхватит, указывайте неподвижный тег.

Ограничение внешних источников

Внешние действия подчиняются списку разрешённых доменов, который задаёт администратор установки — allowed_external_action_domains (см. руководство по установке):

Настройка Что разрешено
не задана любой внешний источник
[] — пустой список ни одного: любое внешнее действие запрещено
["git.example.com"] весь домен
["git.example.com/actions"] только этот владелец
["git.example.com/actions/checkout"] только этот репозиторий

Сравнение регистронезависимо и идёт по границе сегмента: git.example.com не совпадает с git.example.com.evil.test, а git.example.com/actions — с git.example.com/actions-evil.

Запрещённое действие отклоняет весь файл рабочего процесса: конвейер по нему не создаётся вовсе — ни зелёный, ни красный. Причину автор видит на коммите — см. «Отклонённый файл рабочего процесса».

Как выполняется действие

docker://образ запускается сразу: контейнер этого образа с рабочей копией, смонтированной в /workspace (он же рабочий каталог). У остальных форм платформа читает action.yml (или action.yaml) в каталоге действия и смотрит runs.using:

runs.using Что делает платформа Что нужно в среде задания
composite Выполняет шаги run: действия python3 (разбор action.yml) и bash
docker Берёт runs.image: Dockerfile — собирает на месте, docker://… или имя образа — запускает готовый. Рабочая копия монтируется в /github/workspace docker
node12, node16, node20, node* Запускает runs.main в каталоге действия node

Любой другой using (как и отсутствие action.yml) — ошибка шага. Оболочка у составного действия своя, не как у задания, — см. «Оболочка».

Права рабочей копии после контейнера действия платформа возвращает сама, и его же снимает — см. «Права файлов после контейнеров» и «Судьба контейнеров после задания».

with — входные параметры

Каждая пара из with: приходит в действие переменной окружения INPUT_<ИМЯ>: имя переводится в верхний регистр, - заменяется на _. Имя, которое не является идентификатором окружения, пропускается молча. Docker-действие получает те же INPUT_*.

- uses: myorg/actions/fmt@v1
  with:
    check-only: 'true'      # внутри действия: $INPUT_CHECK_ONLY

Экспорт живёт до конца задания, а не до конца шага: INPUT_* одного действия остаются в окружении и достаются следующему — локальному, клонированному и действию с using: docker (шаг uses: docker://… — исключение, ему передаются только его собственные). Это следствие общего сеанса: если действие ведёт себя по-разному при заданном и незаданном параметре, задавайте параметр явно.

Чего из GitHub Actions нет
Не поддержано Что это значит на практике
Выражения ${{ … }} Не вычисляются нигде, в том числе в with: — значение уходит в действие буквально. В run: подстановку делает оболочка ($VAR)
inputs: из action.yml default: не подставляется, required: не проверяется: действие получает ровно то, что задано в with:
Выходные данные действия $GITHUB_OUTPUT, ::set-output и steps.<id>.outputs.* не читаются. Данные между заданиями передаются через $CI_OUTPUT (см. «outputs»)
args:, entrypoint: docker-действия Игнорируются: контейнер идёт с ENTRYPOINT/CMD своего образа
runs.env, runs.pre, runs.post Не выполняются
Вложенность составного действия Берутся только его шаги run:; uses:, shell:, if: и working-directory внутри действия игнорируются
timeout, working-directory, shell на шаге Работают только на шаге run:. У шага uses: они игнорируются; if, env и continue-on-error действуют у обоих
Изоляция шага Действие идёт в общем сеансе со всеми run:-шагами задания: те же переменные, тот же каталог
Каталог действий GitHub (Marketplace) Источников ровно четыре, перечисленных выше; своего каталога действий у установки тоже нет

Оболочка

- run: echo $HOME
  shell: bash        # По умолчанию: sh

Доступные оболочки: sh, bash. При shell: bash шаг выполняется отдельным процессом bash.

Оболочка одна у обоих исполнителей — sh с set -e. Она есть в любом образе, включая alpine и distroless, где bash не установлен. Правило действует одинаково для скрипта задания и after_script, на хосте и в контейнере образа.

Шаг, который выполняется отдельным процессом (shell: bash, timeout), set -e не наследует: многострочный скрипт такого шага не останавливается на первой ошибке, а исход шага берётся от его последней команды. Нужна остановка на первой ошибке — начните такой скрипт с set -e.

pipefail не включён: этой команды нет в POSIX sh, поэтому исход конвейера команд берётся от его ПОСЛЕДНЕЙ команды —

- run: тесты | tee отчёт.log     # падение `тесты` задание НЕ уронит

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

- run: set -o pipefail; тесты | tee отчёт.log
  shell: bash

Составное действие (uses: с using: composite) — исключение: его шаги идут через bash -eo pipefail. Такому шагу нужен bash в образе задания, хотя его собственному скрипту он не нужен.

after_script: где он выполняется

Шаги с if: always() и if: failure() выполняются отдельной частью задания — after_script: после всех остальных шагов, в порядке объявления. Шаг с always() выполняется при любом исходе скрипта, шаг с failure() — только если скрипт провалился (в том числе снят по истечении срока). Исход скрипта after_script видит в переменной CI_JOB_STATUS: success или failed. Задать её своим значением нельзя — платформа ставит её поверх переменных задания.

after_script выполняется там же, где скрипт задания. Правило одно у встроенного исполнителя и у внешнего исполнителя:

Задание Где идёт after_script
С image: В контейнере того же образа
Без image: Оболочкой исполнителя на хосте — как и сам скрипт

Отменённое задание after_script не выполняет — ни у одного из исполнителей: отмена значит «прекратить», а не «сделать ещё шаг». Задание, снятое по сроку, after_script выполняет.

Своей директивы срока у after_script нет: срок задания (timeout:) к этому моменту израсходован его скриптом. Предел задаёт установка — GITRIVER_CI_AFTER_SCRIPT_TIMEOUT_SECS (умолчание 300 с), и он один на обоих исполнителей. По его истечении работа прекращается: процессов после себя after_script не оставляет.

Что это значит для задания с image::

  • команды образа (npm, mvn, cargo из него) в after_script доступны — на хосте их могло не быть вовсе или быть другой версии;
  • $CI_WORKSPACE, $CI_PROJECT_DIR и $CI_OUTPUT указывают внутрь контейнера — туда же, куда и во время скрипта;
  • сервисы задания ещё подняты, и алиасы services: разрешаются;
  • контейнер after_script — отдельный контейнер того же образа. Наследуется только рабочая копия: пакеты, установленные скриптом вне её, файлы вне рабочего каталога и фоновые процессы контейнера задания до after_script не доживают;
  • шаг uses: с if: always() идёт в контейнере тоже — значит docker-действию нужен docker внутри образа задания, ровно как такому же шагу без if: always().
build:
  image: node:20
  steps:
    - run: npm ci && npm test
    # npm — команда образа; на хосте исполнителя её может не быть
    - run: npm run report -- --out отчёт.txt
      if: always()

Права файлов после контейнеров

Контейнер работает от root, а рабочая копия монтируется в него на запись. У части образов umask = 0077 (например, redis:7), и файл, созданный в контейнере, выходит -rw------- root:root: исполнитель, который работает от обычного пользователя, такой файл не открывает. Симптом всегда один — задание успешно, а артефактов, отчётов и $CI_OUTPUT нет.

Платформа сама возвращает себе доступ ко всей рабочей копии до всего, что читает её с хоста: сбора артефактов, отчётов, выходных данных и кеша. У задания с image: это происходит после after_script — он идёт в контейнере того же образа и тоже оставляет файлы от root; у задания без image: — сразу после скрипта, и ещё раз после after_script, если в нём поднимался контейнер.

Контейнер Права возвращает
Контейнер задания (image:) Платформа — образом задания
Контейнер действия (uses: docker://…, действие с using: docker, в том числе image: Dockerfile) Платформа — образом этого контейнера
Контейнер, запущенный самим скриптом (docker run, docker build в run:) Автор рабочего процесса

Граница проходит по тому, кто контейнер запустил: о контейнерах, которые запускает сам скрипт, платформа не знает ничего — ни образа, ни того, какие его файлы заданию ещё нужны. Список образов своих контейнеров платформа держит в служебном файле .ci-action-images в корне рабочей копии; в артефакты и кеш он не попадает.

Судьба контейнеров после задания

По той же границе платформа их и снимает — до возврата прав, сбора артефактов и снятия рабочей копии:

Контейнер Как опознаётся Кто снимает
Контейнер задания (image:) и контейнер after_script По имени Платформа
Контейнер действия (uses:) По метке gitriver-ci-job=$CI_JOB_ID — имени у него нет Платформа
Контейнер, запущенный самим скриптом (docker run в run:) Никак Автор рабочего процесса

Это важно при сроке и отмене: они убивают процессы задания, но не контейнеры — убит клиент docker run, а контейнер демона работает дальше. Свои платформа снимает сама; контейнер, поднятый скриптом задания вручную, переживёт его, если автор не запустил его с --rm и не снял в after_script.

Уборка прерванного задания, которое запускало контейнеры, может занять до нескольких секунд: платформа дожидается контейнеров, которые демон ещё не успел создать.

Если ваш шаг поднимает контейнер сам, верните права в том же шаге — одним из двух способов:

steps:
  # 1. Контейнер идёт от вашего пользователя — подходит, если ему не нужен root
  - run: docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/w" -w /w alpine sh -c 'echo дано > out.txt'

  # 2. Контейнеру нужен root — верните владельца сразу после него
  - run: |
      docker run --rm -v "$PWD:/w" -w /w alpine sh -c 'umask 077; make build'
      docker run --rm -v "$PWD:/w" --entrypoint sh alpine -c 'chown -Rh "$(stat -c "%u:%g" /w)" /w'

Когда возврат прав не удался (например, демон с userns-remap), платформа не молчит: в журнале задания появляется строка о том, что права рабочей копии не приведены, — она и объясняет пустые артефакты ниже по журналу.


strategy — матричные сборки

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu, alpine]
        rust: ['1.80', '1.82', stable]
      fail-fast: false
      max-parallel: 4
    image: rust:$MATRIX_RUST
    steps:
      - run: cargo test
  • Порождает задание для каждой комбинации (2 × 3 = 6 заданий)
  • Значения доступны через $MATRIX_<KEY> (в верхнем регистре)
  • fail-fast: true (по умолчанию) — отменяет остальные при первом провале
  • max-parallel — сколько заданий матрицы выполняется одновременно. Предел считает задания, а не ресурсы сервера: он одинаково действует на задания встроенного исполнителя и на задания с runs-on (внешние исполнители), а также на повтор и ручной запуск отдельного задания матрицы. Значение 0 запрещено — рабочий процесс отклоняется при разборе

Имя каждого задания матрицы собирается как <имя> (<значения>) и служит именем файлов его журнала, выходных данных и артефактов. Поэтому значения матрицы не должны содержать /, \, .. и нулевой байт: рабочий процесс со значением вида alpine/git отклоняется с ошибкой при запуске. Полное имя (вместе с суффиксом) ограничено 255 символами.

include / exclude

strategy:
  matrix:
    os: [ubuntu, alpine]
    rust: [stable, nightly]
    include:
      - os: ubuntu
        rust: nightly
        features: experimental     # Дополнительная переменная
    exclude:
      - os: alpine
        rust: nightly              # Не тестируем nightly на alpine
  • include — добавляет комбинации или дополняет существующие
  • exclude — исключает конкретные комбинации

artifacts — артефакты задания

jobs:
  build:
    steps:
      - run: cargo build --release
    artifacts:
      paths:
        - target/release/myapp
        - target/release/*.so
      expire-in: 7d
  • paths — пути и маски того, что сохранить. Читаются они общим правилом масок: от рабочей копии задания, * не пересекает /, ** — ноль или больше сегментов
  • expire-in — время хранения (по умолчанию: 30d)
  • Форматы: 1h, 7d, 30d, 1y, never
  • Артефакты доступны заданиям, которые зависят через needs. Задание без needs получает артефакты тех заданий своего конвейера, что уже успели их сохранить (кроме собственных: повторный запуск начинается с чистой рабочей копии), — порядок выполнения задают только needs, поэтому рассчитывать на такой набор можно лишь объявив зависимость
  • Скачиваются через интерфейс
  • Каталог сохраняется целиком: и когда назван путём (paths: [public]), и когда его накрыла маска (pub*, public/**) — вместе с вложенными файлами и пустыми подкаталогами. Внутрь символических ссылок раскрытие не идёт: сама ссылка в архив попадает, а ссылка/* файлов не даст — иначе ссылка на каталог вне рабочей копии вынесла бы в артефакт чужие файлы. Путь без маски проходит и через ссылку (ссылка/index.html): его автор назвал явно
  • Вся рабочая копия (paths: ['.'], paths: ['**']) сохраняется её содержимым: каждый файл и каталог корня — своей записью, каталоги по-прежнему целиком
  • Служебные записи исполнителя в архив не попадают — ни под маской, ни названные путём: .git-credentials (учётная запись задания с действующим CI_JOB_TOKEN), .docker (учётная запись реестра контейнеров после docker login), .ci-output (файл $CI_OUTPUT) и .ci-action-images (образы контейнеров, которые платформа запустила из скрипта задания, — см. Права файлов после контейнеров). Их кладёт в корень рабочей копии сам исполнитель. От git они скрыты .git/info/exclude, поэтому git add -A в скрипте задания их не добавляет
  • .git в архив не попадает — ни под маской, ни названный путём: в .git/config рабочей копии лежит доступ задания к LFS (lfs.url и заголовок Authorization: Basic с токеном задания), и paths: ['.'] вынес бы его в архив открытым текстом. В обратную сторону правило то же: при восстановлении записи .git из архива пропускаются — .git рабочей копии всегда даёт клон её собственного задания. Задание, которое сохраняло репозиторий с историей (paths: ['.'] для развёртывания), получит архив без .git: нужную историю следует класть в артефакт явным путём (например, git bundle create история.bundle --all)
  • Состав архива по одним и тем же paths одинаков у встроенного исполнителя и у внешнего исполнителя
  • Число файлов под маской не ограничено: paths: ['**/*.js'] в проекте с node_modules собирается целиком, сколько бы файлов маска ни накрыла
  • Имя файла вне UTF-8, попавшее под маску (или в корне рабочей копии, когда сохраняется она целиком), роняет задание со строкой в журнале, а сам файл в архив не попадает — его нужно переименовать
  • Имя файла с обратным слешем (\) в архив не попадает и роняет задание той же строкой — его нужно переименовать: разные сборки tar читают такое имя по-разному, и надёжно упаковать его нельзя
  • Внешний исполнитель получает артефакты зависимостей с сервера своим токеном и только в пределах своего задания (его needs, а без них — своего конвейера); состав называет сервер, каждый исход пишется в журнал задания

Неполный архив — провал задания. Если объявленный файл не удалось прочитать (нет прав доступа, недоступен каталог, в котором его искали), задание завершается с состоянием «провалено», а в его журнале появляется строка «Сбор артефактов: не удалось прочитать часть файлов — в архив попало не всё» с причиной от упаковщика. Правило одно у встроенного исполнителя и внешнего исполнителя. Собранная часть архива сохраняется — по ней видно, чего не хватает. Безобидные предупреждения задание не роняют и лишь отмечаются строкой в журнале: файл, который дописывался во время упаковки, и путь из paths, которого в рабочей копии не оказалось (такой путь просто пропускается — как и маска, не совпавшая ни с чем). Если не нашлось ничего, в журнале задания появляется «Сбор артефактов: ни один из указанных путей не найден в рабочей копии», и задание остаётся успешным.

Отчёты (artifacts: reports:) задание не роняют. Объявленного отчёта (junit, coverage_report, dotenv) может не оказаться — например, шаг упал раньше, чем успел его создать, — поэтому неудача чтения даёт лишь строку в журнале задания: «Отчёт JUnit ‘report.xml’ не прочитан: файла нет в рабочей копии задания» (или причину от ОС, если файл есть, но недоступен). Строка одна и та же у встроенного исполнителя и внешнего исполнителя: по ней видно, почему вкладка «Тесты» пуста, а переменные dotenv не дошли до зависимых заданий. Файл $CI_OUTPUT — исключение: его путь не объявляют, файл создаёт сам скрипт, поэтому строку даёт только недоступный файл, а не его отсутствие.

expire-in: просроченные артефакты проверяются и автоматически удаляются не реже раза в час — то есть фактическое удаление может отстать от expire-in на этот срок. Срок ставит сервер — одинаково для архива встроенного исполнителя и внешнего; если expire-in не задан или значение не распознано, действует умолчание 30d. Размер архива идёт в расход квоты max_ci_artifacts и снимается с неё при удалении. Предел действует у ОБОИХ исполнителей: архив, не уложившийся в остаток квоты, не сохраняется, а задание проваливается — артефакты объявлены, и зависимые задания их ждут.


cache — кеширование между запусками

jobs:
  build:
    steps:
      - run: cargo build --release
    cache:
      key: cargo-$CI_COMMIT_BRANCH
      paths:
        - target/
        - ~/.cargo/registry/
      policy: pull-push
  • key — ключ кеша (поддерживает подстановку $VAR)
  • paths — каталоги для кеширования. В отличие от artifacts: paths: это именно пути, а не маски: кешируют каталоги целиком, и раскрывать здесь нечего. Путь с * или ? поэтому в кеш не попадает вовсе — как несуществующий
  • Вся рабочая копия (paths: ['.']) кешируется её содержимым: каждый файл и каталог корня — своей записью, каталоги по-прежнему целиком
  • Служебные записи исполнителя в архив кеша не попадают — то же правило и тот же список, что у artifacts: .git-credentials, .docker, .ci-output, .ci-action-images
  • .git в кеш не попадает и из кеша не восстанавливается — то же правило, что у artifacts
  • Имя с обратным слешем (\) в кеш не попадает — то же правило, что у artifacts; в журнале задания об этом появляется строка, но задание неполный кеш не роняет
  • policy:
    • pull-push (по умолчанию) — читать и записывать
    • pull — только читать (для заданий, которые не должны обновлять кеш)
    • push — только записывать

При восстановлении кеша права файлов переносятся из архива (биты rwx), чтобы сборочные скрипты cargo и установленные в кеше инструменты оставались исполняемыми. Специальные биты (setuid/setgid/sticky) и запись для прочих из архива не переносятся никогда. То же правило действует для артефактов зависимых заданий; владелец и группа файлам задания не назначаются из архива ни в каком случае.

Время изменения файлов (mtime) переносится из архива тоже — и у кеша, и у артефактов зависимых заданий. Поэтому инкрементальные сборщики (cargo, make, ninja) правильно видят, какие файлы из кеша устарели относительно исходников коммита, и пересобирают их.

Время из архива проверяется:

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

Следствие, которое стоит знать при настройке кеша: файлы рабочей копии клон кладёт заново каждый запуск, то есть со временем «сейчас». Поэтому крейты самого репозитория пересобираются в любом случае, а кеш ускоряет то, что приходит из архива со своим временем целиком, — зависимости (~/.cargo, target/ под них, node_modules).

Кеш хранится на сервере, в каталоге данных CI (ci_data_path), и при восстановлении распаковывается в рабочий каталог задания. Кеш общий для всех конвейеров и веток репозитория: разделяет их только ключ, поэтому в него и подставляют $CI_COMMIT_REF_SLUG. Кеш чужого репозитория недостижим даже по совпадающему ключу.

Конвейер запроса из форка пишет кеш отдельно и подменить общий кеш репозитория — тот, что получают сборки защищённых веток, — не может. Читать общий кеш он по-прежнему может: сначала ищется его собственный, затем общий, — поэтому сборки запросов из форка не начинаются с пустого кеша. От автора рабочего процесса это ничего не требует: key и paths те же.

Кеш сохраняется только после успешного задания: кеш упавшей сборки испортил бы и следующие запуски.

Кеш входит в квоту и вытесняется по сроку. Суммарный размер архивов считается в категории max_ci_cache владельца репозитория — отдельно от артефактов. При исчерпанном пределе кеш не сохраняется, а задание остаётся успешным — в его журнал уходит строка «Сохранение кеша: архив не сохранён на сервере. Квота превышена…». Прежний архив по тому же ключу при этом цел. Замена архива по существующему ключу считается по ПРИРОСТУ размера, а не по полному размеру нового архива: кеш, однажды заполнивший предел, продолжает обновляться, а замена, которая занятое не увеличивает (новый архив того же размера или меньше), проходит даже при исчерпанной квоте.

Архив, к которому не обращались дольше ci_cache_retention_days (по умолчанию 14 дней), снимается фоновым обслуживанием. Срок считается от последнего ОБРАЩЕНИЯ — записи или выдачи заданию, — поэтому кеш обновляемой сборки не вытесняется никогда, а ключ ветки, которой уже нет, уходит сам. Ноль в этой настройке отключает вытеснение по сроку: расход тогда ограничивает только квота.

В отличие от артефактов неполный кеш задание не роняет — он лишь ускоряет следующий запуск. Но если упаковщик не смог прочитать часть файлов, в журнале задания появляется строка «Сохранение кеша: не удалось прочитать часть файлов…», объясняющая, почему следующая сборка пойдёт без части кеша. Пути paths, которых в рабочей копии нет, просто пропускаются.

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


environment — окружение развёртывания

jobs:
  deploy:
    environment:
      name: production
      url: https://app.example.com
    steps:
      - run: ./deploy.sh

Задание связывается с окружением: заданию достаются переменные этого окружения (см. «Уровни переменных»), а список окружений с последним состоянием доступен в настройках репозитория (Настройки → Окружения) и через API GET /api/v1/repos/{owner}/{name}/ci/environments.


Журнал задания

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

Журнал пишется на языке установки (настройка language, см. руководство по установке), а не на языке читателя. Внешний исполнитель своей настройки языка не имеет — язык приходит ему от сервера вместе с заданием. Язык выбирается ОДИН раз на задание: смена настройки посреди сборки не даёт журнала на двух языках, а отметка повтора и строка о снятии задания сервером идут на том же языке, что и остальной журнал. Ручной повтор начинает журнал заново — на языке установки в момент повтора. Вывод самих команд скрипта не переводится: его пишут программы задания.

При языке ru журнал выглядит так:

Выполняется на GitRiver CI
Исполнитель: встроенный исполнитель
Конвейер: #01a097ac · Сборка · refs/heads/main
Коммит: 7f081dca — починить разбор ключа

Подготовка окружения
Образ: rust:1.82
Сервисы: postgres:15
Получение исходного кода
$ git clone …
Восстановление артефактов
Артефакты задания build восстановлены
Восстановление кеша
Ключ: cargo-linux-9f2c
Кеш восстановлен
Запуск сервисов
Сервис postgres:15 доступен по имени postgres
Выполнение скрипта
…вывод скрипта…
After script
…вывод after_script…
Сохранение артефактов
Сбор артефактов: архив сохранён, 12345 байт
Сохранение кеша
Ключ: cargo-linux-9f2c
Сохранение кеша: архив сохранён, 98765 байт
Задание выполнено успешно

Раздел появляется только тогда, когда ему есть что сказать: без image: и сервисов «Подготовки окружения» не будет, без cache: — разделов кеша.

Последняя строка называет ИТОГ, и он же определяет значок задания в интерфейсе:

Строка журнала Состояние
Задание выполнено успешно успех
Задание завершилось с ошибкой (exit N) провал
Задание провалено: артефакты собраны не полностью провал
Задание снято по истечении срока провал
Задание отменено отменено

Задание, которое списал сам сервер (исполнитель замолчал либо не взял задание за ci_runner_max_wait_secs), тоже объясняется строкой в журнале, а не пустотой.


outputs — передача данных между заданиями

Задания могут передавать данные через файл $CI_OUTPUT:

jobs:
  prepare:
    steps:
      - run: |
          VERSION=$(cat VERSION)
          echo "version=$VERSION" >> $CI_OUTPUT
          echo "should_deploy=true" >> $CI_OUTPUT

  deploy:
    needs: [prepare]
    if: $NEEDS_PREPARE_OUTPUTS_SHOULD_DEPLOY == "true"
    steps:
      - run: echo "Deploying version $NEEDS_PREPARE_OUTPUTS_VERSION"

Как это работает:

  1. Задание записывает пары key=value в файл $CI_OUTPUT (по одной на строку)
  2. После завершения задания выходные данные сохраняются на сервере
  3. Зависимое задание получает значения через переменные $NEEDS_<JOB>_OUTPUTS_<KEY>
    • Имя задания и ключ приводятся к верхнему регистру
    • Дефисы в именах заменяются на _
  4. Переменные подставляет сервер — одинаково для заданий встроенного исполнителя и внешних исполнителей, и до решения if: задания: условие по выходным данным зависимости (пример выше) действует на обоих исполнителях. Так же наследуются переменные из artifacts.reports.dotenv зависимости.

Выходные данные собирают оба исполнителя: внешний присылает содержимое своего $CI_OUTPUT серверу, а разбирает его всегда сервер — правила у обоих одни. Исполнитель старее сервера переменную $CI_OUTPUT может не задавать и файла не присылать: зависимые задания получат пустую подстановку, поэтому обновляйте исполнителей вместе с сервером.

Пример с несколькими зависимостями:

jobs:
  detect:
    steps:
      - run: |
          echo "backend=true" >> $CI_OUTPUT
          echo "frontend=false" >> $CI_OUTPUT

  build:
    steps:
      - run: |
          echo "artifact_path=dist/app.tar.gz" >> $CI_OUTPUT

  deploy:
    needs: [detect, build]
    if: $NEEDS_DETECT_OUTPUTS_BACKEND == "true"
    steps:
      - run: echo "Artifact: $NEEDS_BUILD_OUTPUTS_ARTIFACT_PATH"

Переменные окружения

Приоритет (от низшего к высшему)

  1. Переменные уровней установки, группы, репозитория и окружения (экраны «Переменные CI/CD»)
  2. Глобальные env: файла рабочего процесса
  3. env: на уровне задания
  4. Ввод запуска: INPUT_* из workflow_dispatch и CI_MERGE_REQUEST_*
  5. Предопределённые переменные CI_*
  6. env: на уровне шага — применяется внутри скрипта и действует для своего шага

Файл рабочего процесса сильнее настроек, а не наоборот: переменная, заданная в настройках, не может подменить то, что задано в файле, — в том числе значения матрицы и текст скрипта шага. Имена пространств CI_, GITRIVER_, MATRIX_, NEEDS_, __GR_ пользователю закрыты — задать такую переменную нельзя ни на одном уровне.

Ввод запуска сильнее файла: номер запроса на слияние и параметры ручного запуска задаёт сам запуск, и env: их не переопределяет.

Отсюда практическое правило: если значение задаётся и в настройках, и в env:, до задания дойдёт значение из env:. Нужно, чтобы побеждала настройка — уберите имя из env: файла.

Предопределённые переменные

Переменная Описание Пример
CI Всегда true true
CI_PIPELINE_ID Идентификатор конвейера 550e8400-...
CI_PIPELINE_SOURCE Источник запуска (web — ручной запуск) push, pull_request, schedule, web
CI_COMMIT_SHA Полный SHA коммита a1b2c3d4...
CI_COMMIT_SHORT_SHA Короткий SHA a1b2c3d
CI_COMMIT_BRANCH Ветка (для тега не задана) main
CI_COMMIT_TAG Тег (для ветки не задана) v1.0.0
CI_COMMIT_REF_NAME Полное имя ссылки — ветки или тега refs/heads/main или refs/tags/v1.0.0
CI_COMMIT_REF_SLUG Имя ветки или тега, пригодное для адресов и имён файлов feature-my-branch
CI_COMMIT_MESSAGE Сообщение коммита fix: bug #123
CI_SERVER_URL Адрес установки https://git.example.com
CI_REPOSITORY_NAME Имя репозитория myapp
CI_REPOSITORY_OWNER Владелец myteam
CI_REPOSITORY_FULL_NAME Владелец и имя myteam/myapp
CI_REPOSITORY_URL Адрес репозитория https://git.example.com/myteam/myapp
CI_JOB_ID Идентификатор текущего задания 550e8400-...
CI_JOB_NAME Имя задания build
CI_JOB_TOKEN Токен задания — см. «Что может CI_JOB_TOKEN» eyJ...
CI_REGISTRY Адрес реестра контейнеров registry.example.com
CI_REGISTRY_IMAGE Имя образа репозитория в реестре registry.example.com/myteam/myapp
CI_REGISTRY_USER Имя пользователя для реестра gitriver-ci
CI_REGISTRY_PASSWORD Токен для реестра eyJ...
CI_WORKSPACE Корень рабочей копии /builds
CI_PROJECT_DIR То же, что CI_WORKSPACE /builds
CI_WORKSPACE_HOST Та же рабочая копия, каким её путь видит демон docker, — для docker run -v из скрипта задания /var/lib/gitriver/…/repo
CI_OUTPUT Файл выходных данных в корне рабочей копии /builds/.ci-output
CI_CGROUP_PARENT Срез ресурсов CI: cgroup, в которую исполнитель кладёт все контейнеры задания. Пусто — среза нет (см. «Срез ресурсов CI и сборка образов») gitriver.slice
DOCKER_CONFIG Каталог учётных данных docker — свой у каждого задания /builds/.docker

Пути рабочей копии зависят от того, где идёт задание, и жёстко их писать нельзя. У задания с image: рабочая копия смонтирована в контейнер, и $CI_WORKSPACE указывает внутрь него; у задания без image: — на каталог хоста. Сама точка монтирования у двух исполнителей разная (/builds у встроенного, /workspace у внешнего исполнителя), поэтому путь и отдаётся переменной: рабочий процесс, написанный через $CI_PROJECT_DIR, идёт у обоих одинаково, а написанный через /builds — только у одного.

Своему контейнеру рабочую копию отдавайте по $CI_WORKSPACE_HOST, а не по $CI_WORKSPACE. Сокет демона заданиям проброшен, и поднять свой контейнер скрипт может — но демон ищет пути в файловой системе ХОСТА. Когда сервер GitRiver сам работает в контейнере, $CI_WORKSPACE указывает внутрь его пространства имён: такого пути на хосте нет, docker молча заводит по нему пустой каталог, и контейнер получает пустой рабочий каталог вместо репозитория — отказ приходит уже изнутри, строкой вида «нет такого файла».

- name: Упаковка сторонним образом
  run: |
    docker run --rm -v "$CI_WORKSPACE_HOST:/work" -w /work \
      registry.example.com/tools/nfpm:latest pkg --config packaging/nfpm.yaml --packager deb

Вне контейнера (внешний исполнитель на машине, сервер из пакета) переменная равна $CI_WORKSPACE — писать через неё можно всегда.

В $DOCKER_CONFIG исполнитель заранее делает docker login в реестр контейнеров установки за автора репозитория: docker push $CI_REGISTRY_IMAGE работает без ручного входа. Каталог свой у каждого задания и лежит в его рабочей копии — учётные данные не остаются на машине после задания и не попадают ни в архив артефактов, ни в кеш, ни в git status.

Для workflow_dispatch

Параметры запуска доступны как $INPUT_<NAME> (в верхнем регистре):

on:
  workflow_dispatch:
    inputs:
      target:
        type: string
# В steps: $INPUT_TARGET

Для matrix

Значения матрицы доступны как $MATRIX_<KEY> (в верхнем регистре):

strategy:
  matrix:
    node: [18, 20]
# В steps: $MATRIX_NODE

Для pull_request

Переменная Описание
CI_MERGE_REQUEST_IID Номер запроса на слияние
CI_MERGE_REQUEST_SOURCE_BRANCH_NAME Исходная ветка
CI_MERGE_REQUEST_TARGET_BRANCH_NAME Целевая ветка
CI_MERGE_REQUEST_TITLE Заголовок запроса

Для outputs задания

Выходные данные зависимостей доступны как $NEEDS_<JOB>_OUTPUTS_<KEY> (в верхнем регистре):

# Если задание "build" записало "version=1.0" в $CI_OUTPUT,
# то в зависимом задании доступна переменная:
$NEEDS_BUILD_OUTPUTS_VERSION  # → "1.0"

Переменные репозитория

Настраиваются в разделе Настройки → Переменные CI/CD.

Поле Описание
Name Имя (A-Z_0-9, без префиксов CI_, GITRIVER_, MATRIX_, NEEDS_, __GR_ — они заняты предопределёнными переменными)
Value Значение
Masked Скрывать в журналах (значение заменяется на [MASKED])
Protected Выдавать только конвейерам защищённых веток. Конвейер по тегу такую переменную НЕ получает: защиты тегов в GitRiver нет, и признак «защищённый ref» для тега всегда ложен

Секретные переменные (Masked): значение вырезается из каждой строки журнала задания и заменяется на [MASKED]. Маскируются и распространённые кодировки значения (base64, hex, кодирование для URL) — на случай, если скрипт вывел секрет закодированным.

env: из файла рабочего процесса перезаписывает переменную репозитория — см. «Приоритет» выше.

Значение секретной переменной — одна строка длиной не меньше 8 символов. Короткое значение маскирование не скрывает (оно угадывается), зато портит журнал: секрет 1 превратил бы в [MASKED] каждую единицу в выводе. Многострочное не находится построчной маскировкой и утекло бы целиком.

Уровни переменных

Кроме репозитория переменные задаются ещё на трёх уровнях. Каждый следующий уровень перезаписывает предыдущий по имени:

Уровень Где задаётся Кому достаётся
Установка Администрирование → Переменные CI/CD (администратор) всем конвейерам установки
Группа Страница группы → Переменные CI/CD (сопровождающий и выше) репозиториям группы и её подгрупп
Репозиторий Настройки → Переменные CI/CD конвейерам репозитория
Окружение Настройки → Окружения → строка окружения → Переменные CI/CD только заданиям с этим environment:

Уровень окружения адресуется заданием, а не конвейером: переменные окружения production получит задание с environment: production, а соседнее задание того же конвейера без environment: — нет. Задание с окружением, для которого переменных не задано, получает набор остальных уровней без изменений.

deploy:
  environment: production   # получит переменные окружения production
  steps:
    - run: ./deploy.sh
build:
  steps:                    # окружения нет — переменных production не увидит
    - run: make

Правила Masked и Protected, фильтр доверия (запрос из форка, защищённый ref) и маскирование журналов действуют на всех уровнях одинаково.

Кому выдаются секреты

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

Конвейер Обычные Masked Protected CI_JOB_TOKEN
Защищённая ветка да да да да
Обычная ветка да да нет да
Тег да да нет да
Запрос из форка да нет нет нет

Строка «Тег» — не оплошность таблицы: защита в GitRiver задаётся только веткам, поэтому ни один тег защищённым не считается. Отсюда правило для выпуска: переменные, нужные конвейеру по тегу (учётные данные реестра, токен публикации), пометить Protected нельзя — они не придут, и задание выпуска упадёт на проверке своих секретов. Маскировать их (Masked) при этом нужно: маскирование от тега не зависит.

CI_JOB_TOKEN (и равный ему CI_REGISTRY_PASSWORD) — это действующий доступ: он пишет в реестр контейнеров репозитория и используется как учётная запись git задания. Скрипт задания видит его в своём окружении, поэтому конвейеру запроса из форка он не выдаётся — иначе автор запроса получил бы права записи. Задания форка при этом выполняются: собрать и проверить код им ничто не мешает.

Правило одинаково для встроенного и внешнего исполнителя. Повтор задания, ручной запуск и перезапуск конвейера используют доверие исходного конвейера — перезапуск задания запроса из форка секретов родительского репозитория не получит.

Разрешение на запуск чужого кода

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

Конвейер запроса, автор которого не имеет права записи в базовый репозиторий, заводится, но не запускается. Он виден в списке конвейеров и на странице запроса с пометкой «ждёт разрешения»; задания стоят. Запуск разрешает любой участник с правом записи — кнопкой «Разрешить запуск» на странице конвейера или запросом:

POST /api/v1/repos/{owner}/{name}/pipelines/{id}/approve

Разрешение одноразовое и относится к одному конвейеру: следующая отправка в ветку запроса заведёт новый, и его тоже придётся разрешить. Доверие при разрешении не пересчитывается — оно взято с конвейера, поэтому секреты закрыты и после нажатия, даже если автор к тому времени получил права.

Запрос из форка, открытый участником базового репозитория, разрешения не ждёт: преграда стоит на чужом коде, а не на форке как таковом. Секреты такому конвейеру всё равно не выдаются — код приходит из другого репозитория.

Что может CI_JOB_TOKEN

Токен действует только в репозитории своего конвейера — и в git, и в реестре контейнеров. Задание читает и пишет свой репозиторий; к любому другому репозиторию этой установки, включая публичный, доступа нет (403). Область одна и та же для встроенного исполнителя и внешнего исполнителя.

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

Срок жизни токена задаёт ci_job_token_ttl_secs (по умолчанию 8 часов). До истечения он принимается и после завершения задания, поэтому долгий срок на установке с недоверенными участниками лучше сократить.

CI_JOB_TOKEN и CI_REGISTRY_PASSWORD — предопределённые имена: значение под ними всегда выдаёт сервер. Задать их переменной репозитория или через env: в рабочем процессе нельзя — такое значение снимается, а не подставляется.

Секреты на внешнем исполнителе

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

Исполнитель ограничен своей областью (установка, группа или репозиторий), поэтому секреты репозитория уходят только исполнителю, которому этот репозиторий доступен. Отсюда следствие для администратора: исполнитель области «установка» забирает задания любого репозитория и получает их секреты — регистрируйте таких исполнителей только на машинах, которым доверяете; для сторонних машин заводите исполнителя репозитория или группы. Исполнитель группы с признаком «обслуживает подгруппы» забирает задания всего её поддерева — а значит, получает и секреты подгрупп, и выполняет их сборки на своей машине. Признак включает владелец машины; по умолчанию он выключен.

CI_JOB_TOKEN маскируется в журналах внешнего исполнителя так же, как и пользовательские секреты.

Клонирование репозитория внешним исполнителем

Встроенный исполнитель берёт код с диска сервера, а внешний исполнитель клонирует репозиторий по HTTP, поэтому сервер выдаёт ему вместе с заданием отдельные учётные данные только для клонирования:

  • только чтение и только репозиторий своего задания — отправка и запись в реестр контейнеров этим токеном запрещены (403), в отличие от CI_JOB_TOKEN;
  • выдаются и заданиям запроса из форка: без них приватный репозиторий не склонировать, а прав сверх чтения того самого кода, который задание и так выполняет, токен не даёт;
  • не попадают в окружение скрипта задания, в .git/config рабочей копии и в список аргументов процессов. Значение маскируется в журналах.

Срок жизни — 1 час: клонирование начинается сразу после получения задания.

Задание выполняется на коммите своего конвейера. Клон поверхностный (--depth=1), и если ветка успела уйти вперёд, пока задание стояло в очереди, нужный коммит дозагружается по SHA. Если получить его не удалось, задание завершается ошибкой — на чужом коде оно не выполняется.

Git и LFS в рабочей копии внешнего исполнителя

Рабочая копия готовится так же, как у встроенного исполнителя.

git push и git fetch из скрипта задания работают без настройки. Исполнитель прописывает заданию учётные данные git из CI_JOB_TOKEN — собирать адрес вида https://пользователь:$CI_JOB_TOKEN@сервер/... вручную не нужно, git push origin достаточно. Токен лежит в отдельном файле учётных данных, а не в адресе удалённого репозитория, поэтому в .git/config и в выводе git remote -v он не появляется. Задание запроса из форка CI_JOB_TOKEN не получает (см. таблицу выше) — отправка из него невозможна.

LFS-файлы приходят содержимым, а не указателями. Исполнитель настраивает адрес LFS этой установки и токен к нему до получения файлов рабочей копии (checkout) — иначе задание собрало бы файлы-указатели вместо содержимого без единой ошибки в журнале. Токен доступен только на чтение LFS-объектов репозитория своего задания и маскируется в журналах. В отличие от CI_JOB_TOKEN он хранится в .git/config (lfs.url и заголовок Authorization: Basic) — потому .git и не попадает в архивы артефактов и кеша, даже под paths: ['.']. То же и у встроенного исполнителя. После checkout исполнитель вызывает git lfs pull; если git-lfs на машине исполнителя не установлен, задание продолжается, а в журнал уходит предупреждение. Предел этой операции — --lfs-timeout (GITRIVER_RUNNER_LFS_TIMEOUT, по умолчанию 600 секунд).

Пути рабочей копии и реестр контейнеров — те же, что у встроенного исполнителя. Исполнитель отдаёт скрипту $CI_WORKSPACE, $CI_PROJECT_DIR, $CI_WORKSPACE_HOST, $CI_OUTPUT и $DOCKER_CONFIG и заранее входит в реестр установки, поэтому рабочий процесс, написанный под встроенный исполнитель, переносится на внешнего исполнителя без правок. Каталог учётных данных docker — свой у каждого задания, внутри его рабочей копии: docker login, сделанный самим скриптом, тоже не остаётся в настройках пользователя исполнителя и не достаётся следующему заданию.

Установка без встроенного исполнителя

Настройка ci_local_executor_enabled = false (GITRIVER_CI_LOCAL_EXECUTOR) выключает встроенный исполнитель: сервер задания не выполняет, Docker ему не нужен, вся очередь достаётся внешним исполнителям. Как поднять такую установку, сказано в руководстве по установке.

Что меняется для авторов рабочих процессов:

  • Метки не рассматриваются. Исполнитель нужен любому заданию, включая runs-on: default и задание вовсе без runs-on.
  • Исполнителю нужна метка default. Задание без runs-on заберёт любой исполнитель, а runs-on: default — только исполнитель, у которого метка default объявлена. Если такой метки нет ни у кого, задания с runs-on: default ждут исполнителя до ci_runner_max_wait_secs (по умолчанию час) и завершаются ошибкой. Панель администратора предупреждает об этом на странице исполнителей, мастер установки — при первом запуске.
  • Запасного варианта «выполнить на сервере» нет. Если исполнители не настроены или очередь недоступна, задание сразу завершается ошибкой, а причина пишется в его журнал.
  • cache:, artifacts: и needs: работают как обычно. Кеш между запусками внешний исполнитель восстанавливает и сохраняет через сервер, артефакты зависимостей получает оттуда же; хранится и то, и другое всё так же на сервере в ci_data_path (см. cache). Единственное условие — исполнитель не старее сервера: исполнитель, не знающий обмена кешем, выполняет cache: вхолостую и молча, а не знающий обмена артефактами — получает в журнал 401 и собирается без файлов зависимостей.

Несколько файлов рабочего процесса

.gitriver/
  workflows/
    ci.yml         # Тесты на каждую отправку
    release.yml    # Выпуск при создании тега
    nightly.yml    # Ночная сборка по расписанию
    deploy.yml     # Развёртывание (ручной запуск)

Каждый файл — самостоятельный рабочий процесс со своими событиями запуска. При отправке GitRiver проверяет все файлы и запускает те, чьи on: совпадают с событием.

Отклонённый файл рабочего процесса

Файл, который не удалось разобрать или собрать в конвейер, из запуска выпадает: конвейер по нему не создаётся. Остальные файлы это не затрагивает — отказ всегда пофайловый.

Причину автор видит на коммите: платформа ставит проверку на коммите в состоянии error с контекстом gitriver-ci/<имя файла> и текстом причины. Она видна во всплывающей подсказке у коммита и в проверках запроса на слияние.

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

Отказ попадает в общее состояние коммита, поэтому правило защиты ветки «CI должен пройти» такой файл не пропустит: запрос на слияние с неразобранным рабочим процессом остановится на этой проверке, а не сольётся так, словно проверок не было вовсе.

Причины бывают четырёх видов:

Что не так Пример текста
YAML не разбирается неверный YAML рабочего процесса: …
файл разобран, но содержит ошибку циклическая зависимость в 'needs': задание 'build'
в файле ключ, которого платформа не знает задание 'build': неизвестный ключ 'runs_on'; вероятно, имелся в виду 'runs-on'
действие запрещено списком доменов внешний action 'evil.test/o/r' запрещён политикой: домен/владелец 'evil.test' отсутствует в списке разрешённых (allowed_external_action_domains)

След одинаков на всех путях запуска — отправка, запрос на слияние, расписание, ручной запуск и перезапуск. Ручной запуск и перезапуск, кроме состояния, возвращают ту же причину прямо в ответе API (код 400): за ней не нужно идти на страницу коммита.

Длинные причины в описании проверки сокращаются до 200 символов — полный текст остаётся в журнале сервера.


Примеры

Проект на Rust — сборка и выпуск

# .gitriver/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
    paths-ignore: ['docs/**', '*.md']
  pull_request:

env:
  CARGO_TERM_COLOR: always

jobs:
  check:
    image: rust:1.82
    steps:
      - run: cargo check --all-targets
    cache:
      key: cargo-check
      paths: [target/, ~/.cargo/registry/]

  test:
    needs: [check]
    image: rust:1.82
    retry: 2
    services:
      - image: postgres:16
        alias: db
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
    env:
      DATABASE_URL: postgres://postgres:test@db:5432/test
    steps:
      - run: cargo test --all
    cache:
      key: cargo-test
      paths: [target/]

  clippy:
    image: rust:1.82
    allow-failure: true
    steps:
      - run: cargo clippy -- -D warnings
    cache:
      key: cargo-clippy
      paths: [target/]
      policy: pull

  fmt:
    image: rust:1.82
    steps:
      - run: cargo fmt -- --check
# .gitriver/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*']

jobs:
  build:
    image: rust:1.82
    steps:
      - run: cargo build --release
    artifacts:
      paths: [target/release/myapp]

  docker:
    needs: [build]
    steps:
      - run: |
          docker build -t $CI_REGISTRY/$CI_REPOSITORY_OWNER/$CI_REPOSITORY_NAME:$CI_COMMIT_TAG .
          docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
          docker push $CI_REGISTRY/$CI_REPOSITORY_OWNER/$CI_REPOSITORY_NAME:$CI_COMMIT_TAG

Node.js — тесты матрицей

name: Node.js CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    strategy:
      matrix:
        node: [18, 20, 22]
      fail-fast: false
    image: node:$MATRIX_NODE
    retry: 1
    steps:
      - run: npm ci
      - run: npm test
    cache:
      key: npm-$MATRIX_NODE
      paths: [node_modules/]

Развёртывание с ручным подтверждением

name: Deploy

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Окружение'
        type: choice
        options: [staging, production]
        required: true
      skip_tests:
        description: 'Пропустить тесты'
        type: boolean
        default: false

concurrency:
  group: deploy-$INPUT_ENVIRONMENT
  cancel-in-progress: false

jobs:
  test:
    if: $INPUT_SKIP_TESTS != "true"
    image: rust:1.82
    steps:
      - run: cargo test

  # Без тестов развёртывание идёт сразу; с тестами — только после их успеха
  deploy:
    needs:
      - job: test
        optional: true
    environment:
      name: $INPUT_ENVIRONMENT
      url: https://$INPUT_ENVIRONMENT.example.com
    steps:
      - run: ./scripts/deploy.sh $INPUT_ENVIRONMENT

Монорепозиторий — условные задания через outputs

name: Monorepo CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  detect-changes:
    steps:
      - run: |
          # Проверяем, какие пути изменились, через git diff
          if git diff --name-only HEAD~1 | grep -q '^backend/'; then
            echo "backend=true" >> $CI_OUTPUT
          else
            echo "backend=false" >> $CI_OUTPUT
          fi
          if git diff --name-only HEAD~1 | grep -q '^frontend/'; then
            echo "frontend=true" >> $CI_OUTPUT
          else
            echo "frontend=false" >> $CI_OUTPUT
          fi

  backend-test:
    needs: [detect-changes]
    if: $NEEDS_DETECT_CHANGES_OUTPUTS_BACKEND == "true"
    image: rust:1.82
    steps:
      - run: cd backend && cargo test

  frontend-test:
    needs: [detect-changes]
    if: $NEEDS_DETECT_CHANGES_OUTPUTS_FRONTEND == "true"
    image: node:20
    steps:
      - run: cd frontend && npm test

Конвейер с повтором и уведомлением об отказе

name: CI with notifications

on:
  push:
    branches: [main]

jobs:
  test:
    image: rust:1.82
    interruptible: true
    retry:
      max: 2
      when: [script_failure]
    steps:
      - run: cargo test

  notify:
    needs: [test]
    if: failure()
    allow-failure: true
    steps:
      - run: |
          curl -X POST "$SLACK_WEBHOOK" \
            -H "Content-Type: application/json" \
            -d "{\"text\": \"CI failed on $CI_COMMIT_BRANCH ($CI_COMMIT_SHORT_SHA)\"}"

Перенос файлов из GitLab CI

Рабочие процессы GitRiver CI берутся только из каталога .gitriver/workflows/. Файлы .gitlab-ci.yml и .gitriver-ci.yml сервер не читает: их нужно переписать в формат этого руководства — по таблице ниже.

Соответствие форматов

GitLab CI GitRiver CI
stages: + stage: needs: (явные зависимости)
script: steps: [{run: ...}]
before_script: Первый шаг в списке
after_script: Последний шаг с if: always() (идёт в контейнере image:, как в GitLab)
when: on_failure if: failure() — у задания или у шага
variables: env:
rules: [{if:}] if: на уровне задания
rules: [{changes:}] on: push: paths:
only/except on: + if:
extends: Нет (якоря YAML, если нужно)
include: Отдельные файлы рабочего процесса
when: manual workflow_dispatch или if:
retry: retry: (сохранён)
allow_failure: allow-failure:
interruptible: interruptible: или concurrency: cancel-in-progress
services: services: (сохранён)
artifacts: artifacts: (сохранён)
cache: cache: (сохранён)
environment: environment: (сохранён)

Пределы ресурсов задания

Задания встроенного исполнителя — с image: и без него — выполняются под одинаковыми пределами. Значения задаёт администратор установки (см. руководство по установке); умолчания такие:

Ресурс Значение Задание с image: Задание без image:
Память 2g (ci_docker_memory) docker --memory memory.max cgroup задания
Своп столько же, сколько памяти умолчание Docker (--memory-swap = 2×память) memory.swap.max cgroup задания
Процессы и потоки 512 (ci_job_pids_limit) docker --pids-limit pids.max cgroup задания
ЦП 2 (ci_docker_cpus) docker --cpus не ограничивается
Открытые файлы 1024 предел образа ulimit -n скрипта
Размер журнала 100 МБ — —
Время выполнения 1 час, максимум 6 часов (timeout) — —

Предел памяти считает занятую память, а не адресное пространство. Среды выполнения, резервирующие десятки гигабайт адресов (JVM, Go, Rust с jemalloc, санитайзеры), под ним работают нормально: значение имеет только то, что задание действительно заняло.

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

Исчерпав предел, задание падает, а причина пишется в его журнал отдельной строкой — со значением предела и именем настройки, которой он поднимается:

Исчерпан предел числа процессов задания: pids.max=512, отклонённых запусков — 12.
Предел поднимается настройкой ci_job_pids_limit.

Срез объясняет себя в журнале задания. Предел среза стоит не на задании: упершись в него, задание получает Killed и код 137 — от собственной ошибки сборки это не отличить. Поэтому исполнитель называет причину отдельной строкой:

Срез ресурсов CI исчерпал предел памяти: ядро завершило процессов — 1.
Срез общий для всех заданий CI, поэтому завершено могло быть и соседнее задание.
Предел поднимается командой: systemctl set-property gitriver.slice MemoryMax=…
Задание ждало ЦП 12 с: срез ресурсов CI упирался в свою квоту. Срез общий для
всех заданий CI — ждать могли и соседние. Квота поднимается командой:
systemctl set-property gitriver.slice CPUQuota=…

Обе строки считаются ЗА ВРЕМЯ задания, а не за всю жизнь среза, и обе говорят, что срез общий: причина может быть не в самом задании, а в занятости машины.

Заданию без image: пределы накладывает cgroup v2. Если серверу она недоступна (cgroup v1, нет делегирования), задание выполняется без пределов памяти и процессов — сервер сообщает об этом в свой журнал при первом задании. Пределы задания с image: от этого не зависят: их накладывает Docker.

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

Срез ресурсов CI и сборка образов

Пределы из таблицы выше принадлежат ОДНОМУ заданию — и потому считают не весь его расход. Шаг docker build внутри задания исполняет не контейнер задания, а демон BuildKit: отдельный контейнер, который демон docker создаёт рядом с контейнером задания, а не внутри него. Ни --memory, ни --cpus задания на него не распространяются.

Поэтому поверх пределов задания стоит срез ресурсов CI — общая cgroup, в которую попадает всё, что запускает сам исполнитель: контейнер задания, after_script, сервисы services:, контейнеры действий (uses:), служебные контейнеры уборки рабочей копии и сам исполнитель вместе с заданиями без image:. Сборщик образов BuildKit в срез не попадает — о нём ниже. Имя среза задание видит в переменной CI_CGROUP_PARENT; чем срез ограничен, задаёт администратор установки (см. руководство по установке).

Своему сборщику образов задавайте пределы сами. Сборщик, который заводит рабочий процесс, в срез не попадает и попасть не может: его создаёт демон docker по просьбе скрипта, рядом с контейнером задания. Опция --driver-opt cgroup-parent=… у docker buildx create проверку проходит, но до контейнера сборщика не доходит — он остаётся в system.slice. Работают пределы САМОГО сборщика:

- run: |
    docker buildx create --name builder-$CI_JOB_ID --use \
      --driver-opt memory=6g --driver-opt cpu-quota=300000
    docker buildx build --push -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .

cpu-quota задаётся в микросекундах на период в 100 000 мкс: 300000 — три ядра. Без этих двух опций сборка не ограничена ничем. Сборщик за автора рабочего процесса платформа не заводит.

Шаг docker build без buildx (сборщик по умолчанию) исполняет сам демон docker, и его шаги ложатся в system.slice — ни в срез, ни под пределы задания, ни даже под пределы службы docker: потомком docker.service они не являются. Ограничить их можно только умолчанием самого демона — ключ cgroup-parent в /etc/docker/daemon.json, — и оно подействует на ВСЕ контейнеры машины, а не только на CI.

Поэтому сборкам образов надёжнее давать buildx со своим сборщиком и его пределами — или выносить их на внешний исполнитель отдельной машины.

Переменная CI_CGROUP_PARENT остаётся полезной там, где контейнер поднимает сам скрипт: docker run --cgroup-parent "$CI_CGROUP_PARENT" … кладёт его в общий срез (действия uses: платформа запускает именно так).


Ограничения

  • Максимум 20 файлов рабочего процесса на репозиторий
  • Максимум 50 заданий на рабочий процесс
  • Максимум 100 шагов на задание
  • Максимум 10 уровней вложенности needs
  • Матрица: максимум 256 комбинаций
  • Срок задания: по умолчанию 1 час, максимум 6 часов
  • Расписание: минимум 15 минут между запусками
  • Размер файла рабочего процесса: максимум 1 МБ
  • paths / paths-ignore: максимум 100 масок

Интерфейс

Вкладка CI/CD в репозитории

  • Список прогонов конвейера — с фильтрами по состоянию и источнику
  • Каждый прогон показывает: состояние, ref (ветка/тег), коммит, источник, время, длительность
  • Постраничная навигация
  • Кнопка «Запустить конвейер»

Страница прогона

  • Задания сгруппированы по этапам
  • Состояние каждого задания и длительность (обновление на ходу для идущих)
  • Задания матрицы группируются: test (node: 18), test (node: 20), …
  • Нажатие на задание открывает его журнал в реальном времени
  • Кнопки: «Отменить», «Перезапустить», «Перезапустить ошибки», «Запустить» (для ручных заданий), «Перезапустить» у отдельного задания
  • Граф зависимостей заданий (переключатель «Этапы / DAG» при наличии needs)
  • Состояния конвейера обновляются на ходу

Ручной запуск

  • Кнопка «Запустить» на вкладке CI/CD
  • Выбор рабочего процесса и заполнение параметров inputs (поля по типу: текст, флажок, список, число)
  • Свои переменные окружения
  • Запуск и переход к прогону

Значки

https://git.example.com/owner/repo/badge.svg
https://git.example.com/owner/repo/badge.svg?branch=develop

SVG-значок в стиле shields.io с текущим состоянием последнего конвейера.


Состояние реализации

Полностью реализовано

  • Автозапуск при отправке по условиям on: каждого файла рабочего процесса
  • Фильтрация по paths/paths-ignore, branches/tags
  • Триггер запроса на слияние
  • Расписание (cron) — проверка раз в минуту, повтор по одной и той же ветке в пределах 120 секунд не запускает вторую сборку
  • Проверки на коммите — запись и чтение через API, общее состояние коммита, учёт в проверках перед слиянием запроса
  • Ручной запуск (workflow_dispatch) с параметрами inputs: string, boolean, choice, number
  • Зависимости между заданиями (needs), в том числе необязательные (optional)
  • Вычисление условий if: — сравнения, регулярные выражения, функции, логические операторы
  • Матрица (strategy: matrix) с include/exclude, fail-fast, max-parallel
  • Группы одновременности (concurrency) с cancel-in-progress
  • Прерываемые задания (interruptible) — автоматическая отмена при новой отправке в тот же ref
  • Выполнение в Docker через image:
  • Сервисные контейнеры (services) с alias и env
  • Артефакты — сохранение и скачивание архивом по маскам путей
  • Кеш (cache) — сохранение и восстановление по ключу, policy: pull/push/pull-push
  • Повтор (retry) — автоматический, с max и условиями when
  • Допустимый провал (allow-failure)
  • Ручные задания (запуск через API и интерфейс)
  • Передача данных между заданиями ($CI_OUTPUT и $NEEDS_<JOB>_OUTPUTS_<KEY>)
  • Переменные репозитория, в том числе скрываемые в журнале (Masked)
  • Журналы и состояния конвейера в реальном времени
  • Несколько файлов рабочего процесса
  • SVG-значки состояния
  • Отмена и повторный запуск конвейера и отдельных заданий
  • Повторный запуск только провалившихся заданий
  • Граф зависимостей заданий
  • Автоматическое удаление просроченных артефактов
  • API окружений развёртывания — список окружений с последним состоянием
  • Исполнители: API управления и распределение заданий по меткам, программа внешнего исполнителя (gitriver-runner: получение задания, выполнение, журнал в реальном времени, артефакты и кеш, отмена)

Частично реализовано

Возможность Что есть Чего нет
Выполнение шагов Шаги задания идут в одном сеансе оболочки: cd, переменные и файлы одного шага видны следующим (кроме шагов, которые идут отдельным процессом) Нет изолированного выполнения шагов и отдельных кодов выхода у каждого шага
Условия шагов if: у шага: выражения, if: always(), if: failure() always() и failure() действуют, только когда составляют условие шага целиком: в составе выражения (failure() && $X) они говорят об исходе зависимостей задания, а не о предыдущих шагах
Действия (uses:) — см. раздел Четыре формы источника, using: composite/docker/node, with: → INPUT_* Выражения ${{ }}, выходные данные действий, inputs.default, args/entrypoint, runs.pre/post