Vetrix Docs

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

  1. allow_failure — a per-job boolean in the pipeline YAML that marks a job's failure as tolerated (soft) rather than fatal (hard).
  2. The gate — a require_pipeline toggle 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, or cancelled (a cancelled job is never softened by allow_failure). A hard failure terminalises the pipeline failed and skips every downstream waiting job — 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 to success. The soft failure is surfaced as a warning via HasSoftFailure (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, table protected_branches) — the one the merge / MR-open / push paths actually read.
  • The Feature-02 model (protected_branch_rules) — richer, with a dormant RequireStatusChecks field.

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 / pending are not hard_failure and so never block.
  • Admin and push-allowlist members bypass the gate (same bypass set as the restrict_push protected-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.)