Vetrix Docs

Authoring a Vetrix CI/CD Pipeline File

A practical guide to writing a Vetrix pipeline file (vetrix-ci.yml). Vetrix's schema is deliberately narrow — it is not GitLab CI or GitHub Actions, and it rejects most of their keys on purpose. This guide documents exactly what the parser accepts and the patterns that work.

Schema scope — this page documents the v1 schema

This page documents the v1 pipeline schema only. Every structure, field table, rejection list and example below describes v1 and nothing else. Unless a line says otherwise, read it as "…under v1", not as a statement about Vetrix in general.

Vetrix has two live schema versions, selected by the top-level version: key by the dispatcher in internal/cicd/parser/parser.go:

version: Schema Implementation
absent v1 — what this page documents internal/cicd/parser/v1.go, ParsePipeline
1 v1 — what this page documents internal/cicd/parser/v1.go, ParsePipeline
2 v2not documented here internal/cicd/parser/v2.go, ParseV2
anything else rejected E_UNSUPPORTED_VERSION

Both versions are live and supported. A v2 document is a different document shape, not v1 plus extras: jobs move under a top-level jobs: mapping, pipeline-wide settings move under defaults:, and several keys change status in both directions. The differences that matter most to a reader of this page are called out in §4.5; for a worked v2 pipeline see SBOM enablement § 3, whose §2 and §3 give the v1 and v2 recipes side by side.

Where a rule genuinely holds under both schemas — allow_failure: is the main one — this page says so explicitly rather than leaving it to inference.


1. Where the file lives

The parser looks for the pipeline definition at one of:

  • vetrix-ci.yml (repo root) — the convention used by vetrix/vetrix
  • .ci/pipeline.yml
  • .vetrix/pipeline.yml

A push or merge to a branch auto-triggers the pipeline (there is no on: trigger block — see §6). You can also trigger manually via the API.


2. Top-level structure

A v1 pipeline file has exactly four kinds of top-level keys. Everything that is not stages, variables, branch_order or version is treated as a job definition. (This document-root layout is v1's. Under v2 every job lives under a top-level jobs: mapping instead, and a v2 file laid out this way is rejected with pipeline defines no jobs (missing jobs: block).)

stages:            # ordered list; stages run sequentially, jobs within a stage run in parallel
  - build
  - test
  - publish

variables:         # global variables, available to every job (string → string)
  A11Y_FAIL_ON: "serious,critical"

hello-world:       # ← a JOB (any top-level key that isn't `stages`/`variables`)
  stage: build
  image: alpine:latest
  commands:
    - echo "hi"
  • stages — optional. If omitted, defaults to build, test, deploy. A job's stage: must be one of these names or the pipeline is rejected.
  • variables — optional global map. Values are strings. v1 only — v2 rejects a top-level variables: block outright and puts pipeline-wide variables under defaults.variables: instead.
  • branch_order — optional ordered list of branch names, low → high (e.g. [develop, stage, master]), declaring the promotion chain the branch-protection / merge-gate layer consumes. Structurally validated when present (entries non-empty, no duplicates, plausible branch names); empty when the key is omitted. Version-agnostic — carried identically by v1 and v2.
  • Jobs — one map key per job. Job names are free-form (e.g. go-test, build-image).

Execution model: stages run left-to-right; all jobs in a stage run in parallel; a stage starts only after the previous stage's jobs finish. By default, any failing job fails the pipeline — but per-job failure tolerance does exist via allow_failure (see §5).


3. Job fields (the complete, supported set)

These are the only fields a v1 job may contain (rawJob in internal/cicd/parser/v1.go). Anything else is either ignored or (for known GitLab/GitHub keys) a hard parse error. The v2 job field set is a different set — see §4.5.

Field Required Type Meaning
stage yes string Which stage; must be declared in stages.
image yes string Docker image the job runs in (e.g. golang:1.25).
commands no* list of strings Shell commands, run in order in the container. (*a job with no commands does nothing useful.)
only no list of globs Run the job only on these branches.
except no list of globs Never run the job on these branches.
variables no map string→string Per-job variables; override globals.
secret no bool If true, all the job's variable values are redacted to *** in logs.
environment no string v1 only. Names a deployment target (e.g. production); accepted by the v1 parser but read by nothing today. v2 rejects the key.
artifacts.paths no list of globs Files to collect when the job reaches a terminal state — both success and failure (only a cancelled job skips collection).
allow_failure no bool If true, a failed job does not fail the pipeline (soft-fail); default false. Valid under both v1 and v2 — see §5.

Minimal valid job:

go-test:
  stage: test
  image: golang:1.25
  commands:
    - go vet ./...
    - go test -count=1 ./...

Required-field errors are anchored to the line:column in the YAML, so a missing stage: or image: tells you exactly where to look.


4. What Vetrix does NOT support (and the Vetrix equivalent)

Every list in §4.1 through §4.4 is scoped to the v1 schema. The v1 parser rejects these with a clear message naming the key — so a copied GitLab/GitHub job fails loudly instead of running with surprising semantics. §4.5 records the places where v2 disagrees, including two keys listed in §4.4 that are real fields under v2.

4.1 Top-level keys that v1 rejects

Source: incompatibleTopLevelKeys in internal/cicd/parser/v1.go.

Rejected key Use instead
on: (GitHub triggers) Pushes/PRs auto-trigger; gate per-job with only:/except:.
workflows: (GitLab rules) only:/except: per job.
include: Not supported — inline the config.
default: Inline the per-job values.
before_script: Prepend the commands to each job's commands:.
after_script: Append the commands to each job's commands: (see §8 for the teardown idiom).
services: No sidecar containers — start dependencies inside commands: via Docker-in-Docker (§8).

4.2 Job-level keys that v1 rejects

script: (→ commands:), steps: (→ commands:), runs-on: (→ image:), jobs: (in v1 jobs are top-level, not nested), services: (→ DinD), before_script: (→ prepend to commands:) and after_script: (→ append to commands:, see §8).

Source: incompatibleJobKeys in internal/cicd/parser/v1.go. Note that before_script:/after_script: are rejected at both the document root (§4.1) and inside a job — a v1 job carrying either is a hard parse error, not a silent drop.

4.3 artifacts: sub-keys that v1 rejects

when:, reports:, expire_in: (incompatibleArtifactsKeys in internal/cicd/parser/v1.go). Only artifacts.paths: is recognised.

Declared artifacts are collected on job success and failure alike — only a cancelled job skips collection — so there is no when: to configure, and the rejection message for when: says exactly this. Retention is operator-managed, so there is no expire_in:. Collection is performed by Runner.collectArtifacts in internal/cicd/runner_artifacts.go, which the worker calls at job teardown regardless of whether the job succeeded or failed.

4.4 Keys that are not in the v1 schema at all (silently ignored)

cache:, tags:, needs:, rules:, retry:, timeout:.

The v1 parser declares no field for any of these and does not list them as rejections, so a v1 job carrying one parses clean and the key does nothing. The Go module cache, for example, is re-fetched per run.

Do not read §4.4 as a claim about Vetrix in general. It is a statement about v1 only. Two of these six keys are first-class fields under v2 — see §4.5.

4.5 Where v2 differs from everything above

This page does not document v2, but a reader who lands here while authoring a version: 2 file needs to know which of the statements above do not carry over. Source: internal/cicd/parser/v2.go.

Keys v1 ignores that v2 supports for real:

Key Status in v1 Status in v2
needs: Not in the schema; silently ignored (§4.4). A v1 job cannot express cross-job dependencies at all. A real job field. JobV2.Needs, validated by ParseV2 against self-reference and undefined jobs, carried to JobConfig.Needs, and enforced at schedule time by validateNeedsGraph / evalNeedsGate in internal/cicd/engine_needs.go (same-or-earlier stage only; cycles rejected).
cache: Not in the schema; silently ignored (§4.4). A real field, at defaults.cache and per job (CacheV2, requiring a non-empty key:), carried to JobConfig.Cache and restored/saved around the job by the runner.

Keys this page presents as supported that v2 rejects:

Key Status in v1 Status in v2
top-level variables: Supported global map (§2). Rejected. Use defaults.variables:.
job-level environment: Accepted, but read by nothing (§3). Rejected outright.

Otherwise unchanged: tags:, rules:, retry: and timeout: are absent from v2 as well — v2 neither declares nor rejects them, so they are silently ignored under both schemas. Every §4.1–§4.3 rejection also holds under v2 (v2 additionally rejects containers: at both levels), though v2's own defaults: block is unrelated to the rejected GitLab default: key.


5. Failure semantics

  • A non-zero exit from any commands: step fails the job.
  • By default, a failed job fails the pipeline. Set allow_failure: true on the job to tolerate the failure instead. allow_failure is a first-class boolean job field under both the v1 and the v2 schema — it is not one of the v1-only fields on this page. It is parsed by both (rawJob.AllowFailure in internal/cicd/parser/v1.go, rawJobV2.AllowFailure in internal/cicd/parser/v2.go), defaults to false in both, is persisted to the pipeline_jobs.allow_failure column, and is read back in internal/cicd/engine.go (which classifies the soft- vs hard-failure split via jobOutcome, defined in internal/cicd/engine_webhook.go) and by internal/cicd/engine_needs.go (evalNeedsGate, so a tolerated dependency does not block a dependent v2 job). A non-boolean value is a parse error under both schemas. See pipeline-reference.md § Tolerating job failures for the full soft-failure behaviour.
  • Commands run in a shell; the first failing command in a &&-chain is what the exit code reflects. To run cleanup regardless of test outcome, capture the result code yourself (§8).

6. Branch filtering: only / except

build-image:
  stage: publish
  image: docker:25-cli
  only:
    - master
    - develop
  commands: [ ... ]

Glob rules (simple, * matches any run of non-/ chars):

  • only: non-empty → the job runs only if the branch matches one entry.
  • except: → the job is skipped if the branch matches one entry.
  • Both empty → the job runs on every branch.
  • Patterns: exact (develop), * (any), prefix (release-*), suffix (*-hotfix).

Note the ref is matched on the short branch name (develop), even though the stored pipeline ref is fully-qualified (refs/heads/develop).


7. Variables, interpolation & secrets

Interpolation: ${VAR} (and $VAR in shell commands) is expanded from the merged variable map: global variables overlaid by per-job variables overlaid by runner-side sources (the push context + operator-injected host env). An undefined ${VAR} is left as-is and logged as a warning.

Predefined CI variables the runner injects (use in commands:): CI_COMMIT_SHA, CI_COMMIT_REF_NAME / CI_COMMIT_BRANCH, CI_PIPELINE_ID, CI_PROJECT_NAME, CI_REGISTRY (instance-relative registry host), and the per-job VETRIX_JOB_TOKEN (see below).

Secrets — the only correct pattern. Never put a secret value in the YAML (not in variables:, not inlined in a command). Instead:

  1. The operator injects the secret into the runner-host process environment (e.g. A11Y_PASS, CHROMATIC_PROJECT_TOKEN).
  2. The job references it and Vetrix expands it at dispatch:
    variables:
      A11Y_PASS: "$A11Y_PASS"      # value comes from the runner host, not the file
    secret: true                    # redacts every job-variable value in logs
    
  3. secret: true makes the runner redact the expanded values to *** in logs.

Per-job registry token. For pushing images, the runner mints an ephemeral, narrowly-scoped VETRIX_JOB_TOKEN (bound to this repo/pipeline/job, registry:write, auto-expiring) and injects it into the job. It is not declared in variables: — use it directly:

- echo "${VETRIX_JOB_TOKEN}" | docker login "${CI_REGISTRY}" --username x-vetrix-runner --password-stdin

Do not wire in a static/account-level registry credential — the per-job token is the supported model.


8. Running service dependencies (Docker-in-Docker)

Because services: is unsupported, a job that needs (say) Postgres starts it itself, using the runner-mounted Docker socket (the daemon is the host's; the image only needs the docker CLI). This is the canonical pattern:

go-integration-test:
  stage: test
  image: golang:1.25
  secret: true
  variables:
    POSTGRES_USER: "vetrix"
    POSTGRES_PASSWORD: "vetrix"
    POSTGRES_DB: "vetrix"
  commands:
    - apt-get update && apt-get install -y --no-install-recommends docker.io ca-certificates
    - docker rm -f vetrix-it-pg 2>/dev/null || true                 # clean any leftover
    - docker run -d --name vetrix-it-pg -e POSTGRES_USER=$POSTGRES_USER ... postgres:16-alpine
    - |                                                              # wait for readiness
      for i in $(seq 1 30); do docker exec vetrix-it-pg pg_isready -U $POSTGRES_USER && break; sleep 2; done
    - PG_IP=$(docker inspect -f "{{.NetworkSettings.IPAddress}}" vetrix-it-pg)   # sibling-container IP
    - export TEST_DSN="postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$PG_IP:5432/$POSTGRES_DB?sslmode=disable"
    - |                                                              # teardown-in-same-shell idiom
      set +e
      go test -tags integration -count=1 ./...
      trc=$?
      docker rm -f vetrix-it-pg >/dev/null 2>&1 || true              # replaces after_script:
      exit $trc

Key idioms this demonstrates:

  • Docker socket access for sibling containers (no services:).
  • Connectivity by container IP (no host port-publish assumption).
  • Teardown in the same shell expression as the test (set +e … capture $? … cleanup … exit) — this is how you get after_script: behaviour.

9. Worked example (full small pipeline)

stages:
  - build
  - test
  - publish

variables:
  GREETING: "Hello from Vetrix"

say-hello:
  stage: build
  image: alpine:latest
  only:
    - develop
    - master
  commands:
    - echo "${GREETING} — pipeline $CI_PIPELINE_ID on $CI_COMMIT_REF_NAME"

unit-tests:
  stage: test
  image: golang:1.25
  commands:
    - go vet ./...
    - go test -count=1 ./...

publish-image:
  stage: publish
  image: docker:25-cli
  only:
    - master
  variables:
    DOCKER_HOST: "unix:///var/run/docker.sock"
  commands:
    - docker version
    - docker build -t "vetrix:${CI_COMMIT_SHA}" -f Dockerfile .
    - echo "${VETRIX_JOB_TOKEN}" | docker login "${CI_REGISTRY}" --username x-vetrix-runner --password-stdin
    - docker tag "vetrix:${CI_COMMIT_SHA}" "${CI_REGISTRY}/vetrix/vetrix:${CI_COMMIT_SHA}"
    - docker push "${CI_REGISTRY}/vetrix/vetrix:${CI_COMMIT_SHA}"

10. Pre-flight checklist (v1)

This checklist is for a v1 file (no version: key, or version: 1). A version: 2 file is checked against the v2 schema instead — see §4.5.

  • Every job has a stage: (declared in stages) and an image:.
  • No services:, before_script:, after_script:, script:, steps:, runs-on: — these are hard parse errors; use the Vetrix equivalents.
  • No cache:, rules:, needs:, tags:, retry:, timeout: — v1 ignores these silently, so they will not error but will not work either. (needs: and cache: do work under v2 — §4.5.)
  • No secret values in the file; use host-env injection + secret: true.
  • Registry auth uses VETRIX_JOB_TOKEN + CI_REGISTRY, not a static credential.
  • Service dependencies start via DinD with teardown in the same shell step.
  • Browser/e2e jobs only run where the live frontend stack + browsers + injected creds are actually provisioned (see the job-results notes).