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 |
v2 — not 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 byvetrix/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 tobuild,test,deploy. A job'sstage:must be one of these names or the pipeline is rejected.variables— optional global map. Values are strings. v1 only — v2 rejects a top-levelvariables:block outright and puts pipeline-wide variables underdefaults.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: trueon the job to tolerate the failure instead.allow_failureis 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.AllowFailureininternal/cicd/parser/v1.go,rawJobV2.AllowFailureininternal/cicd/parser/v2.go), defaults tofalsein both, is persisted to thepipeline_jobs.allow_failurecolumn, and is read back ininternal/cicd/engine.go(which classifies the soft- vs hard-failure split viajobOutcome, defined ininternal/cicd/engine_webhook.go) and byinternal/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:
- The operator injects the secret into the runner-host process
environment (e.g.
A11Y_PASS,CHROMATIC_PROJECT_TOKEN). - 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 secret: truemakes 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 getafter_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 instages) and animage:. - 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:andcache: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).