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 clippy disagrees 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-fmtcargo fmt --all -- --check
pre-commit-clippycargo clippy --workspace --all-targets --all-features -- -D warnings
pre-push-cargo-testcargo test --workspace --all-features
pre-push-audit-rustcargo audit
pre-commit-lint-jsnpx --no-install eslint --max-warnings 0 .
pre-push-run-tests-jsnpm run typecheck / test:unit / test --if-present
pre-push-audit-jsnpm audit / pnpm audit, per lockfile directory
pre-commit-ruff / pre-commit-pyrightruff check . / pyright --warnings
pre-push-pytestpytest
pre-push-audit-pythonpip-audit -r requirements.txt
pre-commit-gofmt / pre-commit-go-vettest -z "$(gofmt -l .)" / go vet ./...
pre-push-go-testgo test ./...
pre-push-audit-gogovulncheck ./...
ban-terms, secrets, large-files, merge-conflict, commit/branch conventions, pull-rebasedeliberately 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:

  1. the signature verifies against .forgejo/allowed_signers (or your platform's path), a file committed in the repository, whose entry is pinned to the amont-attest namespace:

    you@example.com namespaces="amont-attest" ssh-ed25519 AAAA…
    
  2. 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;

  3. the step's own gate is named in gates. The list holds only checks that PASSED — Warned and Unavailable never 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;

  4. 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:

legattestation saysoutcome
macos-latestplatform aarch64-macosskips — that suite really ran here
ubuntu-latestplatform aarch64-macosruns
windows-latestplatform aarch64-macosruns

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 ${{ }} in run:;
  • a step-level env: or shell:, or a working-directory: the gate does not declare as cwd=;
  • any env: or defaults: 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.