The CI backstop
Hooks are advisory by construction: --no-verify and hook.skip are each
one command away, deliberately, and a teammate whose machine never enrolled
runs nothing at all. So the question "what actually stops a bad commit
reaching the default branch" has one honest answer, and it is not a hook —
it is CI, where nothing can be skipped from a laptop.
amont does not run in CI — on purpose
The obvious move would be an amont run step, or a marketplace action.
Deliberately not. amont's job is local ergonomics: one binary
orchestrating many tools with scoped selection, index fidelity, live
progress, and escape hatches a human sitting at the keyboard is entitled
to. CI wants none of that — it wants the real tools, called directly:
- failures attribute to the tool that found them, in the tool's own words, with the platform's log folding and annotations;
- each tool gets first-class caching, matrices, and versions pinned by the workflow, not by whatever binary a runner happens to have;
- there is no second opinion to keep in sync: when
cargo clippydisagrees between laptop and CI, that is a toolchain version question, not an amont question.
The checks that exist only inside amont — ban-terms, the secrets scan,
large-files, merge-conflict, the commit-message and branch-name
conventions, pull-rebase — are deliberately not reproduced in CI. They
are local ergonomics (catch it before it exists) or they have a better
server-side answer (GitHub's own push protection and 100 MB limit,
platform merge tooling). Losing them in CI loses nothing the tools below
don't already guard: a debugger; that slips past the local hook still
has to survive the test suite and review.
One thing this repository points CI at does run there, and it is deliberately
not amont: attest, a
single-purpose verifier that reads a signed note and reports which checks it
covers. It runs no checks and has no opinions to keep in sync — it verifies a
document about work that already happened, which is the opposite of the
second-opinion problem above. It lives in its own repository precisely so this
rule can stay written as it is; amont is still not installed on any runner.
The templates
Copy the file for your stack into .github/workflows/ (GitHub) or
.forgejo/workflows/ (Forgejo), then prune the steps your repository does
not use. Each step is annotated with the amont check it mirrors, so the
local and CI stories stay legible against each other.
Or fetch one directly:
$ curl -fsSL https://raw.githubusercontent.com/fredericrous/amont/main/templates/ci/github/rust.yaml \
-o .github/workflows/checks.yaml
What maps where
| amont check (local) | CI step |
|---|---|
pre-commit-cargo-fmt | cargo fmt --all -- --check |
pre-commit-clippy | cargo clippy --workspace --all-targets --all-features -- -D warnings |
pre-push-cargo-test | cargo test --workspace --all-features |
pre-push-audit-rust | cargo audit |
pre-commit-lint-js | npx --no-install eslint --max-warnings 0 . |
pre-push-run-tests-js | npm run typecheck / test:unit / test --if-present |
pre-push-audit-js | npm audit / pnpm audit, per lockfile directory |
pre-commit-ruff / pre-commit-pyright | ruff check . / pyright --warnings |
pre-push-pytest | pytest |
pre-push-audit-python | pip-audit -r requirements.txt |
pre-commit-gofmt / pre-commit-go-vet | test -z "$(gofmt -l .)" / go vet ./... |
pre-push-go-test | go test ./... |
pre-push-audit-go | govulncheck ./... |
ban-terms, secrets, large-files, merge-conflict, commit/branch conventions, pull-rebase | deliberately local-only — see above |
One shape carries over exactly: the audits. Locally they warn on a branch
push and block a v* tag push; the templates express the same split
natively with continue-on-error: ${{ !startsWith(github.ref, 'refs/tags/v') }} — advisory red on branches, a hard failure when a
release is leaving the building. That mirrors what this repository's own
release workflow enforces for itself.
Keeping the two in step
The hook is the fast feedback; CI is the same verdict, slower and
unskippable. When they disagree, it is almost always a tool version —
pin the versions your workflow installs, and consider a
tool pin in amont.conf so the
laptop warns when it drifts from what CI runs.
Skipping what pre-push already proved
"CI is the backstop" does not require CI to repeat work it can verify
happened. When pre-push has just run the suite against the pushed tree
and every block gate passed, re-running the identical suite on the
identical tree buys a second copy of the same answer — real money on a
resource-constrained runner fleet. So amont can leave a receipt:
ssh-keygen -t ed25519 -N "" -f ~/.ssh/amont-attest # once, per machine
git config amont.attest true # per repository
With the toggle on, a push whose pre-push block gates all passed writes a
note on each pushed tip in refs/notes/amont-attest and pushes that ref
alongside the branch. The note is a four-line payload plus an SSH
signature over exactly those bytes:
amont-attest-v2
tree <the tree the gates ran against>
gates pre-push-cargo-test pre-push-audit-rust
platform aarch64-macos
amont 1.9.0
-----BEGIN SSH SIGNATURE-----
…
-----END SSH SIGNATURE-----
This does not repeal "amont does not run in CI". CI still never
executes amont — the templates' attest step is
fredericrous/attest, which needs
nothing but git and ssh-keygen, and skips a test step only when all four
hold:
-
the signature verifies against
.forgejo/allowed_signers(or your platform's path), a file committed in the repository, whose entry is pinned to theamont-attestnamespace:you@example.com namespaces="amont-attest" ssh-ed25519 AAAA… -
the attested tree is byte-for-byte the tree CI checked out — not the commit hash: a reword keeps its attestation, a single changed byte loses it, and a PR merge commit whose tree drifted from the tested tip never skips;
-
the step's own gate is named in
gates. The list holds only checks that PASSED —WarnedandUnavailablenever appear, because "could not run" is not "passed" — so a CI step with no local mirror (an e2e suite, an image build) is never skippable by construction; -
the attestation's platform is the platform asking. A pass is a pass on something.
A matrix asks the fourth question for you
cargo test green on an arm64 Mac is no evidence at all about the Windows
leg of a matrix — and a mechanism that let one laptop retire three
platforms' CI would be the unsound version of this whole idea. So the note
records where the suite ran, and amont attest covered defaults to
requiring that platform to equal the verifier's own:
| leg | attestation says | outcome |
|---|---|---|
macos-latest | platform aarch64-macos | skips — that suite really ran here |
ubuntu-latest | platform aarch64-macos | runs |
windows-latest | platform aarch64-macos | runs |
No per-leg configuration: every leg runs the same one-liner and only the
matching one finds anything covering it. amont's own CI is the worked
example — a laptop push retires the macOS leg's cargo test and leaves
the other two exactly as they were.
For a suite whose result genuinely does not depend on where it ran — a pure-JS unit run, most pytest suites — the workflow says so, once, in a line that is committed and reviewed like any other:
- id: attest
uses: fredericrous/attest@v1
with:
platform: any
That is a claim about the suite, so make it where the suite is defined rather than on the signing side: the machine holding the key should not get to decide that its results travel.
Reading the result
The action publishes two outputs. Use gates:
- run: cargo test --workspace
if: ${{ !contains(fromJSON(steps.attest.outputs.gates), 'pre-push-cargo-test') }}
gates is a JSON array, and contains over an array matches elements.
The other output, covered, is the same names as a string, where contains
matches substrings — so a gate named pre-push-cargo-test-slow satisfies a
check for pre-push-cargo-test and skips the real suite. covered is kept
only for workflows written against the older inline templates; new ones should
not use it.
Everything is fail-open, and the action says why it found nothing rather than leaving you guessing:
attest: attested on aarch64-macos, this leg is x86_64-linux
attest: no attestation found for tree 9f2a1c…
If you would rather not depend on an action, the same verifier is a single
shell script — verify.sh
— and amont attest covered still does the same job locally for CI that is
neither GitHub nor Forgejo. Both answer to the format in
SPEC.md.
Origin is the source of truth. amont attest covered treats the local
refs/notes/amont-attest as a mirror of origin's: deleting that ref on
origin revokes every attestation it held — the next covered in any clone
deletes its copy and prints how to undo that — and an origin that cannot be
reached covers nothing, with the reason on stderr. The spec in
.github/attest-inputs may mark a path with a leading ? (?build.rs): it
may be absent, and the gate re-runs the day it appears.
What signs is the machine that ran the tests, so the trust statement is
exactly "whoever holds amont.attestKey vouches for this tree" — the same
trust you extend by pushing at all when you are the only committer. On a
team, that key is a shared authority: hand one to each developer (one
allowed_signers line each) or accept that any holder can mint "tests
passed". And the failure doctrine is the gate stamps' own, one direction
only: no note, an unknown format version, a foreign or tampered note, a
mismatched tree, a signer CI never heard of — every one of them reads as
"no attestation", and no attestation means CI runs the tests. The
mechanism can only ever save a redundant run; it cannot skip a check that
did not happen. --no-verify skips pre-push entirely, mints nothing, and
CI quietly does the full job — which is the backstop doing exactly what it
is for.
Skipping lint: tree gates
Test gates are signed from pre-push. Lint and format are different: the
pre-commit linters see staged files only, so their pass says nothing about
the whole tree. A tree gate (a tree line in amont.conf) runs the
whole-tree command at commit, beside the other checks, and its pass is
attested as tree-<name>:
- id: attest
uses: fredericrous/attest@v1
with:
anywhere: tree-eslint # formatting and lint do not depend on the platform
- name: Lint
if: ${{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), 'tree-eslint') }}
run: npm run lint
Command parity
An attestation names a gate, and a gate is worth only the command behind it. So the CI step must run exactly the gate's normalized declaration:
- the declared command;
- with
{cache}removed; - then a dangling trailing
--removed; - then whitespace collapsed.
tree eslint eslint * attest npm run lint -- {cache} requires
run: npm run lint. Tool resolution is part of the command
(uvx ruff@0.16.0, uv run, npm run), so CI and the laptop resolve the
same tool. One gate proves one command: two CI steps (ruff check and
ruff format --check) are two gates.
amont tree-parity (and the pre-commit-tree-parity check) enforce this. It
fails closed on a gated step it cannot read with certainty:
- a block scalar, an anchor or alias, a quoted form with escapes, or
${{ }}inrun:; - a step-level
env:orshell:, or aworking-directory:the gate does not declare ascwd=; - any
env:ordefaults:the job or workflow hands down.
The one inherited setting it accepts is defaults: run: shell: bash, and only
for a simple command: no pipe, list, redirection or substitution. There,
bash's -e and pipefail cannot change the verdict. Run
amont tree-parity as an ungated CI step, so a drift committed with
--no-verify still fails.