Pipeline branch protection
This document is the architecture write-up for the required-pipeline branch
gate: how Vetrix lets a protected branch refuse changes until the relevant
commit has a passing CI pipeline, and the per-job allow_failure flag that
feeds it.
This document describes how the gate works. For the operator/author-facing
how-to, see
user-docs/protected-branches.md and
user-docs/cicd/pipeline-reference.md.
Two moving parts
allow_failure— a per-job boolean in the pipeline YAML that marks a job's failure as tolerated (soft) rather than fatal (hard).- The gate — a
require_pipelinetoggle on a protected branch that, when set, blocks merge / MR-open / push on a hard-failing pipeline.
The bridge between them is a single computed value: the required-pipeline verdict for a commit.
allow_failure: state + flag, no new job state
allow_failure is parsed from the job definition in both the v1 and v2 pipeline
parsers (internal/cicd/parser/v1.go, v2.go) and carried unchanged through the
v1→v2 migration (internal/cicd/parser/migrate.go). It defaults to false, and
a non-boolean YAML value is rejected at parse time. It is persisted on
pipeline_jobs.allow_failure (migration 000201, NOT NULL DEFAULT false, so
every pre-existing row keeps the hard-fail behavior).
A soft (tolerated) failure introduces no new job or pipeline state. A
tolerated job that fails stays in the failed job state; the "softness" is
derived by reading allow_failure alongside the state, not by adding a
soft_failed state. The engine's advancer
(advancePipelineStagesLocked in internal/cicd/engine.go) classifies each job:
- Hard failure =
failed && !allow_failure, orcancelled(a cancelled job is never softened byallow_failure). A hard failure terminalises the pipelinefailedand skips every downstreamwaitingjob — a later-stage publish/build job must never run on a red build. - Soft failure =
failed && allow_failure. It is a terminal success-equivalent: the run advances past its stage, and a pipeline whose only failures are soft rolls up tosuccess. The soft failure is surfaced as a warning viaHasSoftFailure(internal/cicd/status.go) so consumers can distinguish a clean green from a green-with-tolerated-failure.
The required-pipeline verdict (computed, not stored)
The gate checks exactly one thing per commit: RequiredPipelineVerdict(repoID, commitSHA) in internal/cicd/required_verdict.go. It returns one of:
| Verdict | Condition (on the latest pipeline for the SHA) |
|---|---|
missing |
No pipeline exists for the commit. |
pending |
The latest pipeline is not terminal yet. |
hard_failure |
The latest terminal pipeline has any cancelled job, or any failed job with allow_failure=false. |
soft_only |
Every failure is tolerated (failed && allow_failure); no hard failures. |
success |
No failed or cancelled jobs. |
Why computed, not a stored commit-status row
The verdict is a computed helper rather than an aggregated vetrix/ci
commit-status row materialised in the database. It is recomputed from current
job states on every call, so:
- A pipeline retry that flips a job's state naturally yields the recomputed verdict. The verdict is recomputed idempotently on retry by construction — there is no stored verdict row to overwrite or reconcile.
- There is no write path, no drift between a cached status and reality, and no new outbox/notification machinery to keep a status row in sync.
The aggregation rule is the same hard/soft rule the engine advancer uses, kept in
its own file (classifyJobStages) reusing the existing read helpers
(ListPipelinesForCommit + ListJobStagesForPipeline) through a narrow
interface seam so it is unit-testable with no live database.
The gate lives on the simple ProtectedBranch model
Vetrix has two protected-branch models:
- The simple model (
git.ProtectedBranch, tableprotected_branches) — the one the merge / MR-open / push paths actually read. - The Feature-02 model (
protected_branch_rules) — richer, with a dormantRequireStatusChecksfield.
The gate toggle is added as require_pipeline on the simple model (migration
000202, NOT NULL DEFAULT false), because that is the model already in the
enforcement paths. Feature-02's RequireStatusChecks is left dormant — the
gate is not wired through it. This avoids threading a second model into the hot
paths and keeps one source of truth for "does this branch gate on CI".
The toggle is set via the simple-model API
(internal/api/protected_branches_simple.go):
GET /api/v1/repos/{owner}/{repo}/protected-branches # list
PUT /api/v1/repos/{owner}/{repo}/protected-branches/{pattern} # upsert (repo-admin)
The {pattern} is a chi wildcard so slashed patterns (release/*) pass through
without percent-encoding. A successful write emits repo.protected_branch.updated.
Three enforcement surfaces
All three resolve the verdict through a narrow interface seam so internal/git
never imports internal/cicd (the same import-cycle avoidance as
PipelineTrigger / RepoIndexer). The strictness differs by surface:
Merge (strict)
MergeRequestService.Merge (internal/git/merge_pipeline_gate.go, surfaced by
PullHandler.Merge in internal/api/pulls.go) allows the merge only on
success / soft_only. hard_failure / pending / missing each map to a
machine-readable reason (pipeline_hard_failure / pipeline_pending /
pipeline_missing) returned as HTTP 409 with a repo.merge.pipeline_blocked
audit row. Merge is strict because it is the last gate before code lands — CI
must have actually finished and passed.
MR-open (lenient)
MergeRequestService.Open (surfaced by PullHandler.Create) blocks on
hard_failure only. pending and missing are allowed because an MR is
usually opened right after a push, before CI finishes. A block is HTTP 409
(pipeline_hard_failure) with a repo.mr.pipeline_blocked audit row.
Push (fail-open, transport-side)
The pre-receive hook (internal/git/hooks/pre_receive.go) has no database
access, so it cannot compute a verdict itself. The transport layer
(internal/git/pipeline_gate.go, wired into both the SSH and HTTP smart-protocol
paths) pre-resolves, for each gated branch, the branch's current tip SHA and that
tip's verdict, then injects them into the hook environment as
VETRIX_PIPELINE_GATE_REFS (modeled on VETRIX_PROTECTED_REFS).
The hook rejects a push only when the pushed new SHA equals the injected tip
SHA and the verdict is hard_failure. Every other case fails open:
- A push advancing the branch to a new SHA (the common case — the push is what triggers the pipeline) does not match the injected tip and is allowed.
missing/pendingare nothard_failureand so never block.- Admin and push-allowlist members bypass the gate (same bypass set as the
restrict_pushprotected-branch check).
Because the hook has no DB, the transport recomputes the match post-exec
(gateBlockMatch) to emit the repo.push.pipeline_gate_blocked audit row.
Independent of restrict_push
require_pipeline and restrict_push are decoupled. VETRIX_PROTECTED_REFS
(direct-push restriction) is built only from rules with restrict_push=true,
while the pipeline gate (VETRIX_PIPELINE_GATE_REFS) is built from the full,
unfiltered rule set. A rule with require_pipeline=true, restrict_push=false
therefore gates merge / MR-open / push-on-hard-failure but does not block a
normal non-admin push that advances the branch (and triggers the next pipeline).
Audit events
| Event | Emitted when |
|---|---|
repo.protected_branch.updated |
A protected-branch rule (incl. require_pipeline) is upserted. |
repo.merge.pipeline_blocked |
A merge is refused by the gate. |
repo.mr.pipeline_blocked |
An MR-open is refused by the gate (hard_failure). |
repo.push.pipeline_gate_blocked |
A push is rejected by the transport-side push gate. |
pipeline.job.hard_failed |
A job reached a hard (non-tolerated) failure. |
pipeline.job.soft_failed |
A job reached a soft (allow_failure) failure. |
(repo.protected_branch.updated requires actor_user_id, repo_id, pattern,
require_pipeline in its details.)