Status: Accepted
The pipeline, the release process, and the checks that gate a merge.
Satisfies: roadmap M10, E2, GOV-R enforcement.
PR pipeline — lemonfiber
flowchart LR
pr[PR] --> spec[spec-check]
spec --> fmt[rustfmt]
fmt --> lint[clippy strict]
lint --> arch[arch tests]
arch --> unit[unit + golden]
unit --> integ[integration mocked]
integ --> sec[secret scan + cargo-deny]
sec --> e2e{Docker available?}
e2e -->|yes| boot[boot forms]
e2e -->|no| done[pass]
boot --> done
Ordered cheapest-first so a formatting failure doesn’t wait on a compile. Every stage blocks merge.
| Stage | Gate |
|---|---|
spec-check |
Citation present, resolves, spec merged first (GOV-R) |
rustfmt |
Formatting (Q-R19) |
clippy |
The full strict set; warnings are errors (Q-R12) |
| arch tests | Boundaries, no #[allow] in src/, comment policy (Q-R13, Q-R7) |
| unit + golden | Logic and command construction (Q-R23) |
| integration | Mocked Docker and service APIs |
| secret scan | No credential in any tracked file |
cargo-deny |
Licences, advisories, banned/duplicate deps |
| coverage | 100% of applicable lines (cargo-llvm-cov, Q-R61) |
| SonarCloud | Analysis ingested; zero open issues enforced in CI (Q-R64) |
| e2e (conditional) | Boot forms where Docker is present |
The comment gate in CI
The comment policy runs as an arch test, and — critically —
against its fixture tree (Q-R7): it must find each planted violation and
pass the compliant control. A comment gate that only runs over production source
passes vacuously on a young repo. Running it over deliberate violations is what
proves it still works.
cargo-deny
Supply chain is a real threat (security), enforced not exhorted:
| Check | Fails on |
|---|---|
advisories |
A dependency with a known RUSTSEC advisory |
licenses |
A dependency licence outside the allow-list |
bans |
A banned crate, or a telemetry-carrying one (G8-R11) |
sources |
A dependency from an unapproved registry |
G8-R11 — dependencies must not introduce telemetry — is checked here rather than
hoped for.
CodeQL — the analysis is not the gate
Q-R53 puts SAST in CI, and for years that was where it stopped. The check
GitHub raises beside a CodeQL analysis compares the configurations a pull
request produced against every one seen on main; the supply-chain scan uploads
three that only ever run on main, so on a pull request that check is neutral
whatever the analysis found. Requiring it gates nothing. A finding would be
raised, shown in the Security tab, and block no merge.
So the question is asked directly, by a reusable
codeql-alerts.yml
each repository’s own codeql.yml calls: of the alerts the API holds against
this branch, is any of them open. An alert dismissed with a reason is not open,
which leaves the judgement about what is worth acting on where it was recorded.
The script that answers is spec’s, checked out at the revision the caller pins.
The copy each repository carries is held byte-identical by the shared-files
check and never decides, so a pull request that edits it is not judged by its
edit.
Two things that gate must not do, both of which an earlier copy of it did:
| It would pass on | Because | So it |
|---|---|---|
| a commit nobody analysed | code-scanning/alerts returns [] for analysed and clean and for never analysed alike |
reads the analysis list alongside, and refuses a commit carrying none |
| a commit already replaced | analyses?ref=… answers about the ref, and a pull request’s merge ref is rebuilt on every push |
is handed a commit sha and asks about that |
It waits, rather than races: an upload the analyze job accepted is not an
analysis the API will answer about yet, and the two are minutes apart. The same
function decides the wait and the verdict, so there is no second opinion to
drift. Where the analysis already failed, it asks once and is red in seconds —
running after a failed analysis is safe now precisely because it refuses a
commit it has no analysis of, by language and by name.
The languages come off the caller’s own workflow — strategy.matrix where there
are several, init’s languages: where there is one — rather than being passed
in beside it. A second copy of that list is free to fall behind silently, which
is how a gate comes to check two of three and report a pass.
A caller adopting it adds codeql to its awaiting-maintainer wait list in
the same change. That list is what re-asks is this mergeable yet when a
workflow finishes; a gating workflow left out of it can be the last one to go
green, with nothing afterwards to notice — the flag never goes up and nothing
says why. The workflow need not be a required context first: naming it costs one
extra evaluation and means the list is already right on the day it becomes one.
The job runs under if: ${{ !cancelled() }}. needs: alone made it vanish
when a language failed, and a skipped required check satisfies branch
protection — so the
one gate that refuses an alert was absent in exactly the case where the analysis
went wrong. always() would also finish a run somebody pressed stop on, which
serves nobody.
Secret scanning
Runs over all tracked files including tests (Q-R29). A real key in a fixture
is a leak whatever the intent. This is defence-in-depth behind the allow-list
redaction (C4) — the
redaction protects the operator’s secrets; this protects the project’s.
SonarCloud — enforced in CI, not by the plan gate
Code quality and coverage run through SonarQube Cloud, which ingests the
cargo-llvm-cov report (Q-R52, Q-R61). But the free plan cannot set the
quality gate to this project’s standard: its gate is fixed around an 80%
new-code coverage default and cannot be configured to require 100% coverage or
zero open issues. Relying on Sonar’s own gate would let a sub-standard change
merge.
So the standard is enforced in CI directly, independently of the Sonar plan gate, and both checks block the merge:
| Enforced in CI | How | Fails on |
|---|---|---|
| Coverage | cargo-llvm-cov --fail-under-lines 100 over the applicable set |
Any applicable line uncovered (Q-R61) |
| Issues | A step that reads the summary SonarCloud posts on the PR when its analysis finishes | Any open issue — bug, vulnerability or code smell (Q-R64) |
The issues check blocks on a counted issue and on nothing else. A summary that
never arrived, and one whose shape the check no longer understands, are the
analysis’s problem or the check’s own; blocking a contributor’s pull request on
either would punish the wrong person, and — worse — an unreadable summary that
fails looks exactly like a summary that failed, so the real finding hides behind the
noise. Both cases warn instead, in the log and in the verdict comment, which says
plainly that Q-R64 is not being enforced until someone fixes the check.
It also waits for a summary of the commit under test. SonarCloud edits one comment in place, so after a push the previous analysis’s comment is still there with its old count — and a check that reads the first count it finds fails the push that fixed the issue it is reporting, then passes on a manual re-run. A re-run as the remedy is the bug wearing a hat, so the comment is believed only once it is newer than the commit it describes.
This is the documented cap Q-R63 calls for. Reason: the free plan’s gate is
not configurable. Lift condition: a paid plan or self-hosted SonarQube whose
gate can be set to 100% coverage and zero issues — at which point Sonar’s gate and
the CI checks say the same thing and the CI issue check becomes belt-and-braces
rather than the enforcement.
The issue check reads what the analysis already reports rather than asking the SonarCloud API for it: SonarCloud posts a summary on the PR when its run finishes, and the CI step reads that summary and fails on any open issue — turning “Sonar found something” from advisory into blocking without a second credential or a separate query. Where the scan did not run there is no summary to read, and the check does not fail for something it could not observe.
Two kinds of run reach it that way. A pull request from a fork is given no
secrets at all. A pull request Dependabot opened reads secrets from the
Dependabot store rather than the Actions store, and SONAR_TOKEN is not in it —
so a CI-driven scan there receives an empty token and can only fail, on a step
that has nothing to do with the bump, holding a required check red on every
dependency update. The scan is skipped on those runs instead, keyed on
pull_request.user.login — the field Q-R55 already keys on, and the only one
the pull request’s opener cannot write.
Neither run changes what the coverage gate measures: it needs no token and still
runs, so Q-R61 is enforced on a dependency update exactly as it is elsewhere.
Both leave Q-R64 unenforced, and both say so — in the log, and in the verdict
the gate writes to the run summary as well as to the pull request. A skipped scan
reports itself under its own name too, because a step missing from a log reads
the same as a step that ran and found nothing.
Release — cargo-dist
A tagged release on lemonfiber triggers the three-platform build:
flowchart TD
tag["tag v0.4.0"] --> build[cargo-dist build matrix]
build --> mac["macOS<br/>aarch64 + x86_64"]
build --> lin["Linux<br/>gnu + musl"]
build --> win["Windows<br/>x86_64"]
mac & lin & win --> art[Signed artifacts + checksums]
art --> gh[GitHub Release]
art --> tap[Regenerate homebrew-tap formula]
art --> inst[Shell + PowerShell installers]
| Output | For |
|---|---|
| Per-platform archives + checksums | Direct download, and every installer |
homebrew-tap formula |
brew (homebrew-tap) |
install.sh / install.ps1 |
curl | sh, irm | iex |
| Signed release | Integrity |
No web build runs here. The app is compiled in lemonfiber-web’s CI and carried
as a pinned submodule, so the Rust build sees files (ARCH-R19, ADR-0012).
Real cross-platform testing
Not “it compiles” — actually run. The release matrix builds all targets; a
smoke-test job runs the binary on macOS, Linux and Windows (roadmap M10
exit). A binary that builds for Windows and panics on first launch has been
tested for the wrong thing.
lemonfiber-media-stack and homebrew-tap pipelines
lemonfiber-media-stack—spec-check, then the structural checks in its repo spec. No stack boot in CI (no credentials).homebrew-tap— the shared gates only. It holds one generated file and no formula-specific check yet.
Branch protection
Every repo: required checks must pass, spec-check among them, before merge
(roadmap M0.5). The override bypasses
spec-check only, never the build, tests, or review (GOV-R19).
When a pull request is blocked and does not say by what
GitHub names a failing check and links its log. It does not name a required
check that never reported — that one appears in no check list at all, so the
merge box says “Required statuses must pass” with nothing to click. A paths:
filter that did not match, a job whose needs: dependency failed, a reusable
workflow granted too few permissions:, a workflow that could not start, and a
third-party app having an outage all look identical from outside, which is to say
they look like nothing.
just blocked <owner/repo> [<number>…], or just blocked <owner> --org, reads
what protection requires against what the head commit carries and names the
difference. It also reports the rules that are not checks — strict,
required_signatures, conversation resolution, review — which block just as hard
and appear in no list either.
Read it as a second opinion, not an authority: where it and GitHub disagree it says so, and GitHub decides.
A push replaces the run it supersedes
The organisation’s repositories share one pool of runners, twenty jobs at a time. A pull request pushed again has no use for the run on its old head: the checks that decide the merge are the ones on the new head, and a run nobody will read holds runners the new one is queued behind. One repository’s pipeline, run to the end on every superseded push, is enough to leave every other repository’s checks queued behind it.
So a pull request’s workflows cancel the run they replace. A push to main and
a release tag cancel nothing: what runs there is the record of the branch and
the build of a release, and each has a ref of its own that a pull request’s run
never shares.
Every such workflow carries the one block in
shared/concurrency.yml, which keys a pull
request’s runs on its ref and every other event on its own run. The hygiene
gate’s shared-files job holds each repository to it through
check_superseded_runs.py, which runs from this repository’s main and so
reaches every caller without moving a pin. It refuses a workflow a pull request
runs without the block, a group that cancels and is not the block, a job with a
group of its own in a workflow a pull request runs, and two workflows sharing
the name the group is keyed on. A job waiting in a group is replaced by the next
one to arrive whatever cancel-in-progress says, which is why a job’s group is
refused there even when it cancels nothing.
The reusable workflows this repository’s own pull requests also run directly
cannot carry the block: a group declared in one is evaluated in each caller’s
context, where it is the caller’s own. They are covered by cancel-superseded,
which cancels this pull request’s runs on the heads a push replaced through the
API. homebrew-tap, website-docs.lemonfiber.app and website-lemonfiber.app
are reported by the check rather than refused, and are named in it.
A runner is spent on assurance
Twenty jobs at a time is the whole budget, and a job costs a runner’s start-up whether it then works for ten minutes or six seconds. So the count of jobs is held down as hard as their minutes, and nothing changes what any check asks.
A job that would do nothing does not start. Where the event alone answers
whether a job has anything to do — a workflow_run that did not succeed, a
pull request run nobody will be told about — the answer is a job-level if:,
which is evaluated before a runner is assigned. A step that exits early still
cost a runner to reach.
The shared checks are one job. Measured on 107 pull request jobs on
8 October 2026, the median job ran for 10 seconds and waited 5 minutes for a
runner, nine minutes at the 90th percentile, and a pull request ran 25 to 64
jobs. The wait is the cost, so every shared check that runs in seconds is a
step of one job, gates:
- spec-check’s reading;
- the hygiene checks;
- both security scans;
- workflow pins, DCO, attribution, commit lint and the squash message;
- what the explainers detect.
Each step runs whatever the steps of other checks concluded, so one run reports
every failure. The run’s summary lists each check with its result, and the job
fails when any check failed. Branch protection requires that one context,
gates / gates. The job holds no write permission, so a pull request from a
fork runs it as it is.
What writes to a pull request is a second job, report, which runs no
third-party tool. It closes a pull request spec-check refuses, applies labels
and goals, and posts and removes the citation and explainer comments. It judges
nothing, and a fork’s pull request, whose token cannot write, skips it.
Nothing that used to run is dropped: the same checks run, packaged as steps.
The tests, coverage, CodeQL’s analysis and Sonar stay jobs of their own. They run for minutes rather than seconds, and some need a runner to themselves.
gates.yml is generated from the reusable workflows that define each check, by
scripts/gen_gates.py, and a gate refuses a generated file that has drifted
from them. Those reusables stay the definition.
Every check in gates shares one runner, so nothing the pull request’s tree
holds is run or imported there. Python starts isolated: code it reads runs with
-I, and a script of spec’s with -E -s, which keeps the script’s own
directory first on the path and the working directory off it. The generator
refuses a step that starts Python any other way. The verdict runs last, from an
empty workspace, outside it.
What a scanner reads as the list of what to pass over is the base branch’s copy,
never the pull request’s. That covers .gitleaks.toml and .gitleaksignore,
osv-scanner.toml, lychee.toml and .lycheeignore, the root typos.toml
(typos reads no other), actionlint’s and shellcheck’s configuration, and the
.gitignore files that decide which files a scanner reads. A change to one of
them judges the pull requests after it merges, never the one that carries it.
A bump that changes nothing is not pushed. A bot that regenerates a
repository against an upstream compares what it would commit with what its
open branch already carries, and leaves the branch alone where they match. A
newer upstream move cancels a bump still running, whose result it would
overwrite. check_superseded_runs.py lets a group of its own cancel only on a
workflow that nothing but a dispatch or a schedule starts, which no push and no
tag can reach.
A cache is written by main. A pull request can read its own caches and its
base branch’s, never another pull request’s, so a cache saved on one pull
request’s ref serves only that pull request while evicting main’s against the
repository’s limit. Caches are saved from a push to main only, and every job
whose cache a pull request restores also runs there.
An analysis runs where its language changed. On a pull request, CodeQL’s
actions analysis runs when the change touches .github/, and reports success
having analysed nothing otherwise. A push to main and the weekly schedule
analyse every language.
A change that touches no code
A pull request that changes only documentation holds runners for a build, a test
suite, a coverage run and an analysis that cannot answer differently than they
did on its base. So a repository’s code-judging jobs ask a what changed job
first, and skip where every changed path is one none of them reads.
The classification is an allowlist, and it fails safe. A path counts as code-free
only where the repository names it as one no code-judging job reads: its Markdown
outside anything a test or generator reads, its docs directory, its tracker,
LICENSE, its issue templates. Everything else is code, so a new kind of file
runs every job, and so does any change to a workflow, a build file, a lockfile,
a generated contract or a source file. A what changed job that fails, or finds
no base to compare against, decides nothing, and every job runs.
The gates that judge documentation run on every change: markdown, links, typos, spec references, a tracker’s generator check, secret scanning, DCO and attribution.
Each skipped job is skipped by a job-level if: rather than by a paths:
filter, so it still reports, as skipped, and a skipped required check satisfies
branch protection. Three shapes need more than that, because they report under
a different name when skipped:
- A job that calls a reusable workflow reports as the caller’s job name alone,
gaterather thangate / gate, so it is never skipped.sonar-gateandcodeql-alertstakecode: falseinstead and answer at once: no analysis ran, and none was needed. - A matrix job reports with its matrix unexpanded, so a required
analyze (rust)goes missing. Its steps are skipped instead, and the job reports success having analysed nothing. - A check another service posts, such as
SonarCloud Code Analysis, appears only where its scan ran, so a repository requiring one keeps the scan.
Requirements
| ID | Requirement |
|---|---|
| Q-R30 | PR CI MUST run spec-check, format, strict clippy, arch tests, unit, golden, integration, secret scan, cargo-deny, the coverage gate and SonarCloud analysis, all blocking. |
| Q-R31 | The comment and boundary arch tests MUST run against their fixture trees, not only production source. |
| Q-R32 | cargo-deny MUST fail on advisories, disallowed licences, banned crates, and telemetry-carrying dependencies. |
| Q-R33 | Secret scanning MUST cover all tracked files. |
| Q-R34 | Releases MUST build macOS (arm64 + x86_64), Linux (gnu + musl) and Windows, with checksums. |
| Q-R35 | The release MUST regenerate the Homebrew formula and produce shell and PowerShell installers. |
| Q-R36 | A release smoke test MUST run the binary on all three platforms, not merely build it. |
| Q-R37 | Every repository in the org MUST require passing checks before merge; the override MUST bypass only spec-check. |
| Q-R64 | Open SonarCloud issues MUST be zero, enforced as a blocking CI check independent of the Sonar plan’s own quality gate, since the free plan’s gate cannot be configured to this standard (Q-R63). |
| Q-R67 | A repository that has not yet reached zero MUST declare what it still carries, in its own workflow, as a number that MUST NOT increase beyond what already stands against the branch it merges into. |
| Q-R75 | A workflow a pull request runs MUST cancel that pull request’s run it supersedes when the pull request is pushed again, and MUST NOT cancel a run for a push to a protected branch or for a tag. |
| Q-R76 | A job whose only outcome for an event is to do nothing MUST be skipped by a job-level condition the event answers, so that no runner starts for it. |
| Q-R77 | Superseded by Q-R82: the shared checks are one job whose conclusion is the one context a branch requires, and no check is published as a check run of its own. The number is not reused. |
| Q-R82 | The shared checks a pull request runs that take seconds MUST be steps of one job, each step running whatever the steps of other checks concluded, with the job’s summary listing every check and its result and the job failing when any check failed; that job MUST hold no write permission, MUST NOT run code out of the pull request’s tree, which every tool it calls reads as data, and MUST hand what the pull request wrote to a script only through its environment, and branch protection MUST require it as one context. What writes to the pull request MUST be a separate job that runs no third-party tool and judges nothing. The job MUST be generated from the reusables that define each check, and CI MUST refuse it when it has drifted from them. |
| Q-R83 | A pull request MUST NOT change what a repository’s checks run unless a maintainer approves it: every repository MUST run pin-only from its base branch, on pull_request_target for opened, synchronize, reopened, labeled and unlabeled, running its job for every label, since branch protection reads the check from the newest run of its workflow on the head and a skipped run would hide the verdict before it, checking out nothing of the pull request and holding a token that only reads, and it MUST fail a pull request that changes anything under .github/workflows/ or .github/actions/, read from git’s trees at the merge base and at the head commit the event names, unless each change only moves a pin of a lemonfiber/spec workflow forward along spec’s main at a job’s or a step’s uses, the two documents read by a loader that refuses anchors, aliases, merge keys, a key given twice and explicit tags, or the pull request carries the workflows-approved label for the change its head makes. The approval MUST be read from the forge’s records and never from a commit’s dates: the label is on the pull request now, the last time it was added was by an account the forge names with the admin or maintain role on the repository, the head the branch had then is the one the newest run of the calling workflow for the pull request’s branch at or before that time was for, the branch held that head for at least a minute before that time, the first run at or after it is for that head, and every path the pull request changes against the merge base, under those directories or anywhere else in its tree, names the same object and mode at the merge base and at the head, at that head as at the head being judged, read from git’s trees and never from a rendering of the change; the change MUST be read from the forge each time and never from anything the pull request carries. A label added by any other account, a bot’s included, MUST NOT approve, a head the forge cannot return MUST NOT approve, and a push that changes what the pull request changes, or a change of its base, after the label MUST void it. Only a maintainer adds the label, and only after the user approves that pull request. A tree the forge returns truncated, and a pull request whose head is no longer that commit once the approval is read, MUST fail it, and a push MUST cancel the run for the head it replaced; any other event, and a re-run, MUST NOT cancel a run in progress. Branch protection MUST require it in every repository that calls it. |
| Q-R78 | An automated bump MUST NOT push when what it would commit is identical to what its open branch already carries, and a newer upstream move MUST cancel a bump run still in progress. |
| Q-R79 | A build cache MUST be saved only from a push to the protected branch, and every job whose cache a pull request restores MUST also run on that push. |
| Q-R80 | On a pull request, a CodeQL analysis of the actions language MUST run when the change touches .github/ and MAY otherwise report success without analysing; a push to the protected branch and the scheduled run MUST analyse every language. |
Reaching zero from a backlog
Q-R64 names a number the shared gate did not read for months: it counted the
new issues on a pull request, and new is not open, so findings that predated
the gate were invisible to it. Sixty-one of them accumulated across the fleet
under checks that were green throughout.
The gate reads both now. New issues on a pull request are the contributor’s and
block immediately. The open total is the repository’s, and blocks when it rises
above the number that repository declares — allowed-open in its own
sonar.yml, in the tree, readable without a SonarCloud login.
That declaration is a ratchet and not an exemption, and the gate enforces it
rather than trusting it. A pull request runs the workflow file its own head
declares, so the diff that brings the issues could raise the number that permits
them; the gate reads the same declaration on the base branch and refuses any run
whose number is higher, a first declaration where there was none included. A base
it cannot read is refused too wherever the run declares anything above zero —
not knowing whether the ratchet held is not the same as it having held — and zero
needs no comparison, since no count is below it. The gate also says so in its
verdict when the true count is below the declaration, and a repository that
declares its backlog rather than reducing it is failing Q-R67 whatever the
check reports.
A backlog can arrive without anybody writing a line. An analyser enables a rule, and a repository that had reached zero is carrying issues no diff of its own introduced. “Declare what is left and work it down” is what the allowance is for, and at zero there was no way to say it — the ratchet refused every number above the zero already declared, including on the pull request that fixed them. A gate whose remedy it blocks is a gate nobody can obey, and the only ways past it were an override or a lie.
So a raise is permitted, and only as far as reality: the gate reads how many issues actually stand against the base branch and refuses any declaration past that number. What cannot be declared is headroom. A diff that brings a new issue cannot raise the ceiling to cover it, because the count it would have to point at is the count from before the diff — and the new-issue check has already refused it on its own. A raise the gate cannot check is refused on the same reasoning as a base it cannot read.
That raise is also how a pull request that fixes issues standing on main gets
through the gate:
- It raises
allowed-openin the repository’s own sonar workflow to exactly the number of issues open onmain, and the ratchet accepts that raise. - It merges, and the issues it fixes leave
main. - A follow-up pull request lowers
allowed-opento what is left, usually 0.
The raise is needed because the open total the gate compares against the
allowance is the count on main, and SonarCloud reports no issue as fixed by a
pull request: until the fix has merged, the issues it removes still count
against it. Nor can the count be taken from the pull request’s own branch:
SonarCloud’s Free plan cannot read branch analyses.
Related
- testing-strategy.md — what the test stages run
- security.md — why
cargo-denyand secret scanning matter - 50-governance/cross-repo-ci.md — spec-check
- roadmap M10