Status: Accepted
The external tools the project relies on. Every one is free for public repositories or fully open source — no paid, team, or enterprise tier is required, because none is available to an open project on a free org.
Satisfies: ci-cd, security, roadmap M10.
The constraint that shapes every choice
The org is on GitHub’s free plan. That rules out org-level rulesets, required
workflows, and other Enterprise features — so branch protection is configured
per repo, and anything org-wide is achieved through mechanisms free plans do
have: the .github repo for inherited community files,
reusable workflows sourced from the spec, and
shared config presets.
Every tool below was chosen to work within that constraint.
The toolchain
| Concern | Tool | Free basis | Where |
|---|---|---|---|
| Governance gate | spec-check (in-repo) |
our own | all repos |
| Spec integrity | integrity.py (in-repo) |
our own | spec |
| Code quality + coverage | SonarQube Cloud | free for public | all repos |
| SAST | CodeQL | free for public | each repo shared/adoption.toml records as carrying the alert gate (#358) |
| Secret scanning | gitleaks | OSS | all repos |
| Dependency/vuln scanning | OSV-Scanner | OSS | all repos, via the shared security.yml |
| Supply-chain posture | OpenSSF Scorecard | free for public | each repo that publishes an artefact or the specification; the newest four are outstanding (Q-R59) |
| Rust licences + advisories | cargo-deny | OSS | lemonfiber |
| Coverage generation | cargo-llvm-cov | OSS | lemonfiber → Sonar |
| Workflow lint | actionlint | OSS | all repos |
| Spell check | typos | OSS | all repos |
| Link check | lychee | OSS | all repos |
| Markdown lint | markdownlint | OSS | all repos |
| Dependency updates | Dependabot | native to GitHub | all repos |
| Pre-commit hooks | none — git’s own | — | .githooks/, turned on per clone; see below (OPS-R51) |
| Pre-push guard | .githooks/pre-push |
our own | each repo shared/adoption.toml records as carrying it, via core.hooksPath |
| Task runner | just | OSS | the Rust, spec, stack, brand and site repos; the npm and Composer repos use their own script runner |
| Changelog | git-cliff | OSS | lemonfiber, sdk-ts, sdk-php |
| Release binaries | cargo-dist | OSS | lemonfiber |
| Docs site | Astro Starlight | OSS | website-docs.lemonfiber.app |
| Web lint | ESLint (typescript-eslint) |
OSS | lemonfiber-web, sdk-ts |
| Web format | Prettier | OSS | lemonfiber-web, sdk-ts |
| Web accessibility | axe-core, driven by Playwright | OSS | lemonfiber-web, website-docs.lemonfiber.app |
Why these, specifically
SonarQube Cloud carries quality and coverage
Free for public repos, native GitHub Action, analyses Rust. It ingests the lcov
cargo-llvm-cov produces, so coverage lives there too — no separate Codecov.
Per Q-R61, coverage on
applicable code is a merge gate: cargo-llvm-cov enforces 100% over that set
locally and in CI, and Sonar surfaces the same number. The scope — not a lax gate —
is what keeps it honest; the excluded categories are annotated in the source
(Q-R62).
The free plan’s own quality gate cannot be raised to this standard, so both the
coverage gate and a zero-open-issues gate are enforced in CI directly rather than
by Sonar’s gate (ci-cd,
Q-R64).
Requires a one-time SONAR_TOKEN secret, added to the repo after it exists.
Dependabot, and the gate that cites for it
Every dependency bump must cite GOV-R12 (canonical-spec).
Dependabot cannot write a trailer: commit-message sets a prefix and a scope, and
there is no body, no template and no way to add a line to the pull request. So
spec_check.py supplies GOV-R12 for a pull request GitHub says
dependabot[bot] opened, and then runs the existence check unchanged — the
identifier still has to exist on spec@main, and the run still prints what it
accepted. The exemption reads pull_request.user.login and nothing the pull
request carries; see ADR-0016.
dco needed no change — dco_check.py already exempts a bot-authored commit,
because GitHub authors it and there is no human to attest.
Each repo carries its own .github/dependabot.yml. There is no org-level preset:
unlike Renovate, Dependabot cannot extend a shared config, so the policy lives in
one copy per repo and the copies are kept alike by review.
The policy those copies carry: minor and patch bumps travel as one grouped pull
request per ecosystem, a major is left out of the group so it arrives on its own
carrying the major label, and the lemonfiber/spec shared-workflow pins are
their own group, excluded from the three-day release-age wait — a fixed gate
reaches its consumers without one. Security updates are exempt from the wait by
construction and are never grouped, so each vulnerability arrives as its own pull
request.
What Dependabot does not watch
Renovate was configured to watch four things by regular expression and three more by mechanisms Dependabot has no equivalent of. Renovate never ran, so nothing here regressed in practice — but the intent was real. The two scanner versions are now watched again, by the two different means below; the rest is not.
| What | Where it lives now | Who notices it going stale | To bring it back |
|---|---|---|---|
gitleaks CLI version |
.github/workflows/security.yml — GITLEAKS_VERSION, read both by the step that downloads it and by the step that checks it |
The step that checks it, which fails the run once the release it names has been superseded for more than 30 days | Done, but not by Dependabot — see below |
osv-scanner version |
.github/workflows/security.yml — the google/osv-scanner-action/osv-scanner-action pin |
Dependabot, under github-actions |
Done. The action is a container action, so the uses: pin is what fixes the scanner version |
python-version: written inline in workflows |
nine sites, all "3.12" — seven here, one in lemonfiber, one in brand |
Nobody, until 3.12 leaves support in Oct 2028 | No Dependabot manager reads it; a scheduled check, or .python-version + a linter that reads it |
node-version: written inline in workflows |
nowhere — every repo uses node-version-file: .nvmrc |
n/a | The manager watched nothing; it can simply go |
.nvmrc |
four repos — lemonfiber-web, sdk-ts, website-docs.lemonfiber.app, website-lemonfiber.app |
Nobody. engines: node >=26 in package.json is a floor, not a bump |
No Dependabot manager reads .nvmrc |
| Lockfile maintenance (a weekly refresh with no manifest change) | n/a | Nobody. Transitive dependencies drift until a direct bump moves them | No equivalent; npm update / cargo update on a schedule |
Semver ranges pinned to exact versions (:pinAllExceptPeerDependencies) |
n/a | n/a | No equivalent. Dependabot updates within a range and widens it; it does not pin |
| The dependency dashboard | n/a | n/a | No equivalent. The closest thing is each repo’s Dependabot alerts tab |
Two of the three that matter are now closed. The third, python-version:, is
not, and stays as written above.
The two scanners, and why they are watched differently
Both tools publish a GitHub Action, and calling one turns an unwatched string in
a run: block into a pinned uses: that Dependabot already tracks under
ADR-0009. That worked for one
of them.
osv-scanner is called as google/osv-scanner-action/osv-scanner-action,
pinned by SHA. It is a container action, so the pin selects the scanner image as
well as the action, and Dependabot advances both together. The hand-written
exit-code arm that treated 128 as “no package sources, nothing to scan” is now
the scanner’s own --allow-no-lockfiles flag; the action’s entrypoint maps 128
to success either way, and warns when the flag is absent.
gitleaks cannot be. gitleaks/gitleaks-action requires a GITLEAKS_LICENSE
secret for any repository an organisation owns — still true at v3.0.0, and this
org owns every repository here. The key is free to obtain, but it is a
registration, a secret each caller of the shared workflow would have to forward,
and a licence check against a third-party server standing on the critical path of
a required gate. The action is also not open source: since v2.0.0 it ships under a
Gitleaks LLC end-user licence agreement, where the CLI stays MIT. gitleaks/gitleaks
itself publishes no action, and Dependabot’s github-actions parser skips a
uses: docker://… reference, so the published container is no route either.
So the CLI stays fetched by hand, and a step beside the scan reads the latest
gitleaks release and compares it to the version that just scanned. Being behind is
a notice for the first month and a failure after it — the same split, for the same
reason, as pins in hygiene.yml. An answer that cannot be read after three
attempts is a failure, not a pass: a watch that says nothing when it could not look
is the failure it exists to prevent.
The hooks, and why there is no hook manager
OPS-R51 asks for a pre-commit hook running the fast CI-blocking checks. lefthook
was chosen for it once — a single Go binary, no runtime to install — and six repos
carried a lefthook.yml written to that brief. None ever installed it. No
recipe or package script ran lefthook install, so the configs described hooks
that had never run, and one of them named a scripts/guards.mjs that had been
renamed underneath it.
They could not have been installed alongside the other local hook anyway. The
pre-push guard needs core.hooksPath .githooks, and git then reads that directory
only — anything a manager wrote to .git/hooks is ignored. lefthook 2.x
detects the setting, refuses to install, and offers --reset-hooks-path, which
turns the guard off. The one command that repairs the dead config disables the
working one.
So the hooks are files in .githooks/, and there is no manager:
| file | what it does | shared? |
|---|---|---|
pre-push |
refuses a push that would leave a branch carrying no commit origin/main does not — the shape of a mistake that destroyed two branches and closed their pull requests in one day — and a push straight to main |
yes, byte-identical |
commit-msg |
says now what commitlint, dco and spec-check would say after a push: a conventional subject, a sign-off, a Spec: citation |
yes, byte-identical |
pre-commit |
the fast checks that repository runs — formatting, typos, lint on staged files | no, per repo |
The first two are the same everywhere, so they live in shared/ and
check_shared_files.py holds each copy to the original. The third is not: cargo fmt and prettier are not the same command, and pretending otherwise would put a
tool in a repository that does not have it.
Each skips a check whose tool is absent, so a contributor without typos
installed gets CI’s answer rather than a hook that cannot run. None of them
duplicates a slow gate (Q-R57): tests, clippy and coverage belong to the task
runner rather than the hook, because a hook that takes a minute is a hook people
turn off, and one that is off enforces nothing.
Which of them the task runner actually holds is a per-repository answer, and it is
not all of CI anywhere. lemonfiber’s just ci runs the suite and leaves
coverage to just coverage; spec’s and brand’s run neither. That is fine —
what is not is a recipe saying otherwise, which is the subject below.
Enabling is the hard part regardless, because core.hooksPath is per-clone local
config that no commit can carry. Each repo sets it from a command a contributor
already runs — just ci, npm install/npm ci, composer install — and a clone
nobody has installed dependencies into is genuinely unprotected. What is enabled
where, and where it still does not reach, is in
shared/README.md.
actionlint would have caught a real bug
The workflow-injection guidance exists because untrusted event data in a run:
block is exploitable. actionlint detects exactly that class, plus shellcheck on
embedded scripts. It runs over every repo’s workflows.
just — the task runner
A justfile per repo gives named tasks (just test, just ci, just check) that
run the same commands the pipeline does, so a contributor is not reading a workflow
to find out what to type. It matches the CLI’s own ergonomics: discoverable,
self-documenting, no hidden make magic.
The same commands is not all of them, and the difference is the subject of
the command before a push is not CI. A
repository driving its gate through npm or Composer keeps the steps there and puts
the sentence in a justfile beside it, because package.json has no slot for one
and npm prints none.
Astro Starlight — one docs site for the whole org
Roadmap M10 calls for a published
docs site. It is website-docs.lemonfiber.app, and it renders this specification
alongside each repo’s own documentation rather than restating either
(ADR-0015).
Starlight (Astro, OSS) builds it to a static site served from a Cloudflare Worker’s
static assets, with Pagefind search that runs in the browser and no third-party origin.
lychee and starlight-links-validator check every link, authored and mirrored, so
the published site never ships a broken one.
git-cliff — changelog from history
Because commits carry structured trailers (Spec:) and follow a consistent shape,
a changelog can be generated rather than hand-maintained. git-cliff (Rust, OSS)
does this at release time alongside cargo-dist.
ESLint and Prettier — because the rules that matter are type-aware
Web work — lemonfiber-web and
sdk-ts — lints with ESLint and formats with Prettier.
The rule set doing the work is typescript-eslint’s strictTypeChecked, which asks
the type checker rather than reading the syntax tree: a floating promise, an any
crossing a boundary, an await on something that was never a promise. Those cannot be
answered from a parse, so a linter that only parses cannot implement them, however
fast it is. This is the same bar the Rust side sets with clippy::pedantic and
-D warnings, which is why the two repos are held to it identically
(lemonfiber-web).
Prettier formats and ESLint does not — its formatting rules stay off, so the “one formatter, no arguments” posture (code-standards) holds with one tool deciding layout rather than two arguing about it.
Paired with @axe-core/playwright for the accessibility testing the
contrast contract and
G3 require. Both repositories
that sweep drive axe through Playwright against a built site; neither runs pa11y,
and nothing in the org does.
Anti-drift
The tooling is kept consistent across repos by construction, not vigilance:
- Reusable workflows in the spec repo (
hygiene,security,spec-check) are called by three-line wrappers in each repo. One definition each. - Dependabot config is one file per repo. It is the one thing here that is copied: Dependabot cannot extend a shared preset, so the copies are kept alike by review rather than by construction (ADR-0016).
.githubrepo supplies community files org-wide.shared/in the spec repo holds the files that must be copied because a tool or GitHub reads them from the tree it is given — the two lint configs, the hooks, the gate scripts and the brand assets a README shows. Theshared-filesjob in the hygiene gate fails a copy that has drifted from the one here.- Two homes are not in
shared/. A brand asset’s is the brand repository’s own original, named inshared/assets.sha256;CLAUDE.md’s is this repository’s own copy at the root, because the one link in it is relative to a repository root and would resolve to nothing from insideshared/. - Task-runner recipes are per-repo, because what each repository can run locally is different. What they may not do is claim to be CI while running less of it than CI does — below.
The distinction worth keeping straight: a reusable workflow is not copied, so it
cannot drift. A lint config is copied, because the gate checks out the calling
repository and lints that tree — the org’s copy never arrives. Those two need
different machinery, and conflating them is how four different
.markdownlint.jsonc files came to be live at once.
Adoption is declared, not inferred
Two members of shared/ are adopted rather than required. A repository gating on
no open CodeQL alert is not asked for the gate script, and one whose contributors
have not turned hooks on is not asked for the hooks. What is refused is carrying a
copy that is not the current one.
That leaves absence carrying two meanings at once. Never adopted and adopted, then deleted arrive at the check as the same thing — a file that is not there — and it passes over both and reports that the copies match. Which is true of the copies it looked at, and reads as an account of all of them. One of those two meanings is a script that decides whether a branch merges, no longer running, in a repository whose pull requests still go green.
So adoption is stated in shared/adoption.toml and the
check reads it. Where there was one answer there are now four:
| The register says | The repository holds | Then |
|---|---|---|
| it carries the file | the current copy | passes |
| it carries the file | a copy that differs | refused, as before |
| it carries the file | nothing | refused, naming the file and the repository |
| it carries nothing | a copy | refused — a copy nothing declared is one nothing holds to the canonical version |
A repository with no row at all is refused rather than passed over, because an unknown repository is the silence the register exists to end. The register is the only place that answer lives: a second list able to disagree with it would be the defect rather than the fix, which is why the per-repository half of turning the hooks on is a row here rather than a table there.
A register that is absent, unreadable or naming nobody refuses too. It is the input both checks are decided by, so one that did not arrive is a question that went unasked — and a question that went unasked has never been the same answer as nothing to report.
The first prose held to anything
shared/ covers configuration, hooks, gate scripts and images. It covered no
prose at all, and the largest duplicated blocks in the org are prose: a licence
rationale in 21 places, a footer in 16, the pointer to this repository’s
contributor rules in 13.
CLAUDE.md is where that starts, because it is the cheapest possible case. It is
one sentence — see AGENTS.md — it is the same sentence in every repository
that has one, and there is no local variation to allow for. It was already
byte-identical everywhere it existed, held there by nothing, which is a copy
waiting to drift rather than a rule.
Required rather than adopted, unlike the hooks: GOV-R26 asks every repository
for both files, the guide and the tool-specific name pointing at it. Read
conditionally — if a repository has a guide, check the pointer — the one
repository carrying neither would have been the only one this said nothing about,
and it is the repository where an agent finds no guidance at all.
What the pointer may not become is a second guide. Anything about a particular
repository belongs in its AGENTS.md, which this does not read and which is
deliberately free prose.
The command before a push is not CI
Every repository has one command that stands for CI locally — just ci,
npm run ci, composer ci — and none of them is it. Most of what a pull request
starts here is forge-side: the citation gate, the sign-off, the subject, CodeQL,
the secret scan, the label sync. A machine with a clone cannot run any of them,
and no recipe ever will.
A recipe describing itself as everything CI runs is therefore saying something that cannot be true, and the cost is specific. A contributor who runs it, pushes and goes red learns that the command is not worth running — and then does not run it for the things it would have caught.
check_local_command.py holds the sentence
rather than the coverage. A recipe may leave out as much as it likes; what it may
not do is claim the opposite. Two shapes pass — the clause, which reads best when
there is one omission, and the list, which reads better when there are six:
Everything CI runs bar the image check, which needs the network.
Every gate CI runs but backward compatibility, in order; run
composer bcfor that one.
The floor is the entry point. A repository with no local command, and one whose command nobody described, are both refused — the second because it is the state in which this has no sentence to read and would report that every claim in the repository is honest.
No file outgrows a reader
No tracked file in any repository is over 1,000 lines. Generated output counts: a generated file is read in every diff that moves it, by whoever reads a vendored copy, and by the generator downstream of it. One that outgrows a reader is split where it is generated, so the generator writes a directory rather than one long document.
Two kinds of file are not counted, and the list is closed. Lockfiles (*.lock
and package-lock.json) are written by a package manager and nobody reads them
line by line. Images and fonts are binary or, for an SVG, drawn rather than read.
One kind of file is held to the cap through other files. A file
generated.py registers as generated from sources is
read through those sources, and some cannot be split: a workflow that runs every
check as one job is one file by what it is. It passes where every source is a
tracked file within the cap, and is refused naming the source that is not.
hygiene / lines runs check_line_cap.py, which
holds the cap and the list. It names each file over the cap, its line count and
the cap. It judges, so it is required like every check that does (Q-R81,
Q-R68).
A pin is a copy, and a copy goes stale
Anti-drift above covers what the tooling copies. It does not cover what a
repository pins on purpose, and most of the arrows between these repos are exactly
that: the stack and the web surface reach lemonfiber as submodules at a revision,
lemonfiber-web reaches lemonfiber through a client it takes at a revision,
every repo reaches the docs site the same way, and every uses: in every workflow
names a SHA (ADR-0009). An exact
revision is immutable, which is the point of it and also the failure: nothing about
a pin changes because the thing it names moved on.
Renovate was what was meant to notice. It was configured on every repo and opened no pull request on any of them, so the true answer to “what is watching this pin” was nothing, for every pin here, from the first one written until Dependabot replaced it (ADR-0016).
Dependabot watches the uses: pins and the package manifests. It does not watch a
submodule revision — that stays with the pins notice each consuming repo runs —
and it does not watch a version written inline in a run: block, which is the gap
above.
Two things went wrong under that within a day of each other, and both passed every gate:
- The console was drawn through a client from before four of that client’s own
bugs were fixed, so all four were live in it. A stale key read as “lemonfiber is
not answering” — the client checked
401and the binary answers403, so the page reported the server down and never asked for the one thing that would have fixed it. The event stream never handed over an arrival. A refusal’s own sentence was discarded. The vendored contract predated a kind. Somebody building against the console found one of them again by hand, hours after it had been fixed upstream. - The same repository’s lockfile named a client it was not built from.
package-lock.jsonsat behindpackage.json;npm ciinstalls what the lockfile names and the build then overwrites it, so the output was right and the record was wrong. That is the worse half. A wrong build fails somewhere eventually; a wrong record is what anybody reading the repository believes in the meantime.
Neither is a check behaving badly — both are the absence of one. A pin is not a version any scanner ranks, and a lockfile disagreeing with the manifest beside it is not a state the installer has any reason to object to.
So a pin is watched, and what a build resolved is held to what the repository declared.
The watching fires on the dependency’s own change rather than on a calendar. A weekly sweep is a floor, not a first notice, and seven days is long enough for the stale copy to be the one somebody installs. And the report names the commits not taken rather than counting them: most commits on a client change nothing its consumer reaches, so “behind by eleven” reads as an emergency where the list reads as a decision.
OPS-R48 already does this for one pin — when spec’s reusable workflows move, an
automated PR bumps the @SHA in every consumer in lockstep — and the hygiene gate’s
pins job already reports, in each repo, what shared revision that repo is running.
Neither is peculiar to the release train or to shared CI. This is the general case
of both.
Three notifiers carry it between the repos that pin each other. lemonfiber tells
each SDK when the contract artefact it vendors has moved, and fails when an SDK
could not be told (contract-moved.yml). sdk-ts tells lemonfiber-web on every
push to its own main (sdk-moved.yml). Each consuming repo compares what it holds
against what the repository it pins serves, and names what it has not taken,
through the one comparison spec’s upstream-drift.yml defines, with a reader for
each way a commit is taken: composer.lock, package.json, or a committed copy
and the file recording its commit.
The two that dispatch fail rather than report success when they cannot reach the repository they are telling. A notifier that cannot notify, quietly, leaves the calendar as the only thing looking — which is the state all three exist to end.
Reporting was the half that did not work
The comparison was right from its first run and changed nothing. All three checks were red at once, correctly — both SDKs behind the served contract, the console behind the client — and stayed red for days with nobody acting on any of them. In the Actions list an advisory red and a broken gate are the same red X, and telling them apart meant opening the workflow file to read a paragraph saying so. The reader who would do that is the one who already knew.
The argument for reporting was that a pin is meant to lag: taking a contract change is a deliberate act that arrives as a diff somebody reads, so gating would refuse every unrelated pull request for the whole stretch between the server moving and the consumer catching up. The premise is true and the conclusion is the wrong trade. That stretch is not a grace period — it is the interval during which the repository ships against a copy it has been told is wrong, and holding it open is what the red run was supposed to prevent. The refusal is the point: it puts the catch-up in front of the next change rather than behind it.
So the check runs on pull requests, fails them, and is required on the default
branch (Q-R68). What makes that bearable for an author who touched none of it is
Q-R70 — the failure names the artefact that drifted and the command that
regenerates it, in the failure itself. A gate whose remedy the author has to go
and find is a gate that gets routed around, and “the contract moved” is not an
instruction.
The refusal is not immediate. A repository that pins one moving as fast as
this org’s do would otherwise be refused for the minutes between every upstream
merge and the automated pull request that takes it, which is a gate nobody can
get past rather than one that puts anything in front of anybody. So Q-R68
allows a commit 24 hours on the dependency’s default branch before its absence
is a fault: long enough for an automated bump to open, pass and merge, short
enough that a stalled one is red the next day. workflow-pins and
upstream-drift both use it, and both read it from one named constant in spec’s
scripts, so the number the gates apply and the number a reader is told cannot
differ. Inside the window a commit not taken is a warning naming it and the hour
it becomes a refusal. The window counts from the commit’s time on the default
branch, which a squash merge sets to the moment it merged; a commit dated later
than the run counts as past the window, so a date written by hand can only make
the gate stricter.
Without the window, two pins moving at once would deadlock. A repository taking both the shared workflows and a client would hold two automated pull requests, each red on the pin only the other one moves, and neither could merge first. Inside the window both are green, and each merges on its own.
What counts is a commit that changed something the repository reads — the workflow file a pin names, the files a site renders — not every commit on the branch.
Where the catch-up needs a decision rather than a command, the gate waits, and
Q-R71 requires the waiting to be written in the workflow that would carry it,
with the reason, the work it waits on, and the date it is looked at again. A
repository that gates and one that has not got there yet look identical from
outside unless the second one says so — which is the same failure as the red X,
one level up.
What needs a human, once
These can’t be scaffolded and must be set by hand after creation:
| Action | Where |
|---|---|
Add SONAR_TOKEN secret |
Each repo running Sonar → Settings → Secrets |
Create the fan-out environment with a 20-minute wait timer, deployments from main only |
spec → Settings → Environments |
| Enable Dependabot security updates | Each repo → Settings → Advanced Security. Version updates need no toggle — .github/dependabot.yml is enough |
| Uninstall the Renovate GitHub App | Org → Settings → GitHub Apps. It is still installed and no longer read |
All are one-time and free. Everything else runs from committed config.
Requirements
| ID | Requirement |
|---|---|
| Q-R51 | Every tool in the pipeline MUST be free for public repositories or open source; no paid tier MAY be required. |
| Q-R52 | Code quality and coverage MUST run through SonarQube Cloud, which ingests the cargo-llvm-cov report; the applicable-code coverage gate is enforced per Q-R61. |
| Q-R53 | SAST (CodeQL), secret scanning (gitleaks), and dependency vulnerability scanning (OSV-Scanner) MUST run in CI. |
| Q-R54 | Workflow lint (actionlint), spell check (typos), link check (lychee) and markdown lint MUST run in CI. |
| Q-R55 | A dependency-update bot’s pull requests MUST pass spec-check unattended; where the bot cannot emit the trailer, the gate MUST supply GOV-R12 for it, keyed on the pull request’s author and on nothing the pull request carries. |
| Q-R56 | Shared CI MUST be reusable workflows sourced from the spec repo, not copied per repo. |
| Q-R57 | Pre-commit hooks MUST mirror CI and MUST NOT enforce anything CI does not. |
| Q-R58 | The docs site MUST render the specification from a pinned revision of spec, MUST link-check every page it publishes, authored and mirrored, and MUST be the only published rendering of the specification. |
| Q-R59 | A public supply-chain posture check (OpenSSF Scorecard) MUST run on each repo’s default branch. |
| Q-R60 | Any tool requiring a secret or external app MUST be documented as a one-time manual setup step. |
| Q-R68 | Where a repository depends on another repository in this org at an exact revision, an automated check MUST run on every pull request, MUST fail where the pin has not taken a commit that changed something the repository reads from that dependency and has been on its default branch for longer than 24 hours, naming the commits it has not taken, and MUST be a required status check on the default branch. A check MAY fail sooner; one that lets a commit wait MUST read that window from a single named constant. |
| Q-R69 | A lockfile or equivalent record of what a build resolved MUST name the same revision as the declaration it resolves, and CI MUST fail where the two disagree. |
| Q-R70 | A check required under Q-R68 MUST name, in the failure itself, the artefact that drifted and the command that brings it current. |
| Q-R71 | A repository that cannot yet satisfy Q-R68 MUST state that in the workflow that would gate it — the reason, the work it waits on, and the date the deferral is reviewed — and the check MUST keep reporting until the gate is turned on. |
| Q-R73 | Where a shared file is carried by some repositories and not others, adoption MUST be declared rather than inferred from the file’s presence, and the drift check MUST refuse a repository that declares a file it does not carry and one that carries a file it has not declared, naming the file and the repository in each. |
| Q-R74 | The adoption register MUST be the only record of which repository carries which shared file and MUST hold a row for every repository in the organisation; the drift check MUST refuse a repository it holds no row for, and MUST refuse a register that is absent, unreadable or names no repository rather than reading one as an organisation that has adopted nothing. |
| Q-R81 | A tracked file in a repository MUST NOT be over 1,000 lines, generated output included; lockfiles, images and fonts are not counted, and a file the repository registers as generated from sources is held to the cap through those sources, each of which MUST be within it. The shared hygiene workflow MUST check every repository against the one cap and list, and its refusal MUST name the file, its line count and the cap. |
Related
- ci-cd.md — the pipeline these tools compose
- security.md — the supply-chain and secret threats they address
- 50-governance/cross-repo-ci.md — reusable-workflow anti-drift
- 30-repos/README.md — the
.githubrepo - 70-operations/staging.md —
OPS-R48, the one pin the release train fans out itself