StageFreight CI — the audition contract and how phases gate¶
This is the authoritative description of how the pipeline gates. The control lives in the
ledger, in StageFreight's own code — forge-agnostic, so stagefreight run reproduces it
locally with no forge. The forge renders status and transports the ledger; it never
decides.
The ledger and its three consumers¶
Phases run as separate jobs (fresh checkout each); the shared truth is
.stagefreight/pipeline.json (cistate.State, src/cistate/state.go). Each phase records a
SubsystemState; downstream phases READ the ledger. Three consumers, three needs:
- StageFreight (control) — reads the ledger to decide proceed/skip/deny. The only consumer that influences behaviour.
- Forge (render + transport) — projects the ledger into a status (exit code / job colour) and delivers the ledger between jobs (artifacts). Presentation and delivery, never control.
- Humans (narrate) — explains the ledger.
The audition contract¶
Audition publishes exactly one record — its contract about its subject (the triggering source C):
| field | meaning |
|---|---|
Outcome |
what happened: success / failed |
Blocking |
control: may this subject become a distributable artifact? |
Replacement |
lineage: the commit (C′) that supersedes this subject, if a fix was produced |
Reason |
human text |
Two invariants (both unit-tested in deriveAuditionContract / performGate):
- Blocking is the only control truth. It is false ONLY when nothing blocks (runner
healthy, no fatal finding, no remediable finding, tests pass, deps didn't error). A
remediated source stays
Blocking— the fix is in C′, not in this subject, so building it would ship unfixed source. - Replacement is lineage, never control. Perform reads
Blockingalone.Replacementis consumed by narrate / publish / the forge renderer — never by the build decision.
Phase gating (in-code)¶
Each phase records its own contract; each downstream phase consumes the prior one — never infers from file existence or exit codes:
- Audition (
depsRunner) → records theauditioncontract on every exit (adefer, fail-closed: the zero value blocks). Inspect → Classify (Fatal voids / Remediable runs the deps update) → tests → deps update → commit (recordsReplacement= C′). - Perform (
performPhaseRunner) →performGatereadsaudition.Blocking. Blocking → do not build (fail-closed on a missing contract). Not blocking → build, recordbuild. - Review → gates on
build; recordssecurity. - Publish →
authorizePhasedenies onbuild/security;IsBranchHeadFreshskips a stale distribution. - Narrate → reads the ledger;
narrateAuditionLineageexplains "remediated → C′" or "blocked —".
Forge render + transport (the adapter — not control)¶
The forge does two jobs, both adapter concerns:
- Transport. The ledger must reach the next job. Audition's artifacts are rendered
when: always(ArtifactSpec.WhenAlways, GitLabartifacts: when: always/ Actionsif: always()), so the contract is delivered even when audition failed — otherwise a failed audition would drop its ledger and the in-code gate would be silently bypassed by forge behaviour. Audition isallow_failureso perform runs (and reads the contract) rather than the stage halting. - Render. The pipeline's Success/Warn/Fail is a projection of the contract via exit
codes: perform (not
allow_failure) hard-fails on a dead-end block; a superseded block skips green and shows Warn via the surrounding orange. Control never depends on it.
The deps-autoremediation flow (the driving example)¶
The dependency-remediation bug is what exposed that Audition was the one phase publishing no
contract. The chicken-egg: a Critical (CVSS ≥ 7) osv/freshness finding made lint
abort before the deps update that would clear it ever ran. The fix: Classify lets a Remediable
finding through to the deps update, which remediates and commits C′; the contract records
Blocking:true, Replacement:C′; the follow-up pipeline on C′ lints clean and ships.
State matrix (all in-code, via the contract)¶
| case | contract | perform | pipeline |
|---|---|---|---|
| clean | Blocking:false |
builds | Success |
| bad lint (fatal) / unhealthy / tests-fail / deps-fail | Blocking:true, no repl |
fail | Fail |
| Critical CVE, unremediable | Blocking:true, no repl |
fail | Fail |
| Critical CVE, remediated | Blocking:true, C′ |
skip (superseded) | Warn → C′ ships |
Nothing that carries an unresolved problem passes; a fixable CVE self-heals to a clean ship on the follow-up pipeline.
What is NOT part of control¶
security.fail_on_critical(default false) — the image scan reports; it does not gate. The gate is the audition contract over the source. (An operator may still set it for defence in depth on the built image.)handoff(defaultcontinue) — how the superseded pipeline behaves after a fix commits; a rendering/efficiency choice, not the gate.