attest
Your laptop already ran the tests. Stop paying CI to run them again.
When a pre-push hook runs your test suite, that result is thrown away the
moment the push succeeds — and CI runs the same suite again, on the same
content, for minutes you pay for. attest lets the hook sign what it
proved, and lets CI verify that signature and skip the work.
- uses: actions/checkout@v4
- id: attest
uses: fredericrous/attest@v1
with:
anywhere: ci-fmt # gates whose result cannot depend on where they ran
- id: fmt
run: cargo fmt --all -- --check
if: ${{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), 'ci-fmt') }}
- id: test
run: cargo test --workspace
if: ${{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), 'pre-push-cargo-test') }}
# Last, and only after every gate above: sign what THIS job ran, so the next
# run of the same tree (a re-run, the push to main after the merge) skips it.
- uses: fredericrous/attest/sign@v1
with:
key: ${{ secrets.ATTEST_SIGNING_KEY }}
gates: >-
${{ steps.fmt.outcome == 'success' && 'ci-fmt' || '' }}
${{ steps.test.outcome == 'success' && 'pre-push-cargo-test' || '' }}
That is the whole integration. The || '[]' matters the day the attest step
is skipped — say, on a scheduled run that must never skip anything: a
skipped step's output is empty, fromJSON('') fails the job, and '[]'
reads as "nothing covered", which runs everything. Nothing is skipped unless a signature by a key
you committed to the repository covers the exact tree CI checked out,
on the same platform the job is running — or, for the gates you named in
anywhere, on any platform.
Safety, stated plainly
Nothing here can make an untested tree skip CI. It can only cost a redundant
run. Every failure path — no note, no key, no ssh-keygen, a rebase, a
tampered payload, a signature from a stranger — reports "nothing covered", and
nothing covered means your tests run. The verifier always exits 0; it can never
fail your build.
The price of that is silence, so the action explains itself in the job log:
attest: attested on aarch64-macos, this leg is x86_64-linux
attest: no attestation found for tree 9f2a1c…
attest: covered by you@example.com: pre-push-cargo-test pre-push-clippy
Setup
Three steps, once.
1. Make a signing key and tell your hook runner to use it:
2. Commit the public half as .github/allowed_signers (or
.forgejo/allowed_signers), pinned to the namespace:
you@example.com namespaces="amont-attest" ssh-ed25519 AAAAC3Nza…
Use your real address, the one in the note's principal column. The verifier reads the signer's identity from the signature itself, so a file naming a whole team just works and there is nothing to keep in sync.
This file decides who may skip CI, and it is read from the tree being
verified: a pull request that adds a key to it can attest itself. That is the
same trust as editing a workflow file, so give it the same protection — if
.github/workflows/ is under CODEOWNERS or required review, put
allowed_signers there too.
3. Add the step, as shown at the top. The action is pinned by a moving
v1 tag above; for a step that decides whether tests run, prefer pinning the
commit — each release's notes name it:
- uses: fredericrous/attest@<sha> # v1.x.y
What the action runs
Both actions run git-attest, at the version RELEASE names, and
only an archive whose sha256 is the one written there: the commit you pin
names the bytes it runs, and a swapped release asset is refused. Linux x86_64
runners take the static musl build, so an old glibc or an Alpine container:
job runs it too. The archive is kept in the runner's tool cache and checked
again on every use.
They still fail open. No download, a digest mismatch, a runner with no
release asset, a binary that will not run, a run that fails or writes no
answer: each prints one warning, and verify.sh / sign.sh (git and
ssh-keygen, nothing else) answer instead. A flaky network costs at most a
skip, never a gate and never the job.
The binary: input is test-only: it runs a binary you built in place of
the pinned one (attest's own CI tests each branch that way) and prints a
notice saying so.
What gets skipped, and when
The gate name is the whole contract between your hook and your CI step. A hook
gate called pre-push-cargo-test that runs cargo test --lib does not cover
a CI step running cargo test --workspace, whatever the name says — make them
the same command.
Platform. By default an attestation counts only on the platform it was
minted on, as <arch>-<os>; platform: linux accepts any architecture, and
platform: any accepts anywhere. Most laptops are macOS and most CI is Linux,
so with only a laptop producing, the default skips nothing — which is what
anywhere and the sign action are for. anywhere names the gates whose
result cannot depend on where they ran: formatting, shell lint, secret
scanning, dependency audit. It is a loaded gun. Never list a check that
compiles or executes the product, and that includes clippy: cfg and
target-specific dependencies change what it sees.
The signature covers the tree, so an attestation survives a reword, an
amend, a rebase and a squash-merge. On a pull_request trigger, though, CI
checks out a merge commit the forge just made, and its tree equals your pushed
tip's tree only when the branch is up to date with its base. Otherwise the job
log reads attested tree … is not the checked-out tree … and the tests run —
correctly, because that merged result was never tested. Rebase to get the skip
back, or verify on push.
Unrelated changes. By default an attestation names a whole tree, so any commit anywhere — a README fix on main, another package in a monorepo — moves the tree and voids it. Declare what each gate actually reads and it survives them:
# .github/attest-inputs — literal paths and directories only: git ls-tree
# does NOT glob, `tests/*.sh` would match nothing and fingerprint nothing.
pre-push-cargo-test .github/workflows/ci.yaml Cargo.toml Cargo.lock rust-toolchain.toml src
ci-fmt .github/workflows/ci.yaml Cargo.toml rust-toolchain.toml src
A leading ? marks a path that may be absent — ?clippy.toml, ?build.rs:
while it is absent the gate stays covered, and the day it appears the gate
runs again. A gate is then covered on any tree whose declared paths are
byte-identical to the attested ones (producers from 1.3.0 write the fingerprint into the note;
this file is itself an input, so editing it re-runs everything once). Name
the workflow file too: the gate name is only worth the command behind it.
ASCII paths only, no spaces; name a parent directory instead. Over-broad is
safe, under-broad is a silent false skip — so this file is a trust boundary
exactly like allowed_signers, and belongs in the same CODEOWNERS line.
Plan-only commits. Since 1.5.0 an attestation also covers a tree that
differs only in the content of files under the top-level docs/plans/
(SPEC.md, "Canonical tree"), with nothing to declare. A repository whose gate
reads the contents of a plan (a whole-tree formatter, a link checker) opts out
with one line in its attest-inputs:
@plan-exempt off
Until 2.0.0, also keep set planExempt off in amont.conf: verifiers older
than 1.6.0 know only that form.
The attestation is only as honest as the machine that made it. The signing key is a file on that machine, and anything running there as you can sign with it; so can anything that runs your test suite as you. Nothing here protects against that, any more than a green CI run protects against a developer with push access.
Producing attestations
amont is the reference producer on a
laptop: with amont.attest enabled it signs a note at pre-push naming the
gates that actually passed. Any tool can produce one — SPEC.md is
the format, and it is short.
From CI
CI tests the merge tree during the pull request, then the push to main tests
the identical tree again. fredericrous/attest/sign records the first result
so the second run skips it — and so do re-runs and duplicated matrix legs.
Setup, once:
Then the trailing step shown at the top, with three rules that keep it honest:
- Put it last, never under
if: always(). The defaultsuccess()is what guarantees a failed gate is never signed. - Name only what ran here, with the
steps.<id>.outcome == 'success'pattern. A step that was skipped because it was already attested hasoutcome == 'skipped', and re-signing it would claim someone else's platform as yours. - The job needs
permissions: contents: writeto push the notes ref. That permission is not ref-scoped — GitHub has no notes-only token — so put a ruleset onmain; rulesets apply toGITHUB_TOKENtoo. A signing key in a secret is exactly as trusted as a workflow file: whoever can change.github/workflows/can make CI sign anything.
Fork pull requests get no secret and a read-only token; both paths just report
and succeed. With push: false the block stays on the runner, in
refs/notes/attest-local/amont-attest, and the verifier reads it only with
include-local: true: origin never saw it, so origin cannot revoke it.
Origin is the source of truth. The verifier treats the local notes refs as
a mirror of origin's: deleting them there revokes every attestation they held
(1.4.0 clones delete their copy on the next run, and print how to restore
it), and an origin that cannot be reached covers nothing — the action's
notes output says unreachable and the job log warns. See SPEC.md, "Which
refs are read". A tree your laptop already attested gets CI's block appended
beside yours, each on its own platform. ed25519 keys only. sign@v1 resolves
only from 1.2.0 on.
An attestation is only as honest as the push that produced it, and the way a
push gets dishonest in practice is a coding agent masking its failure —
git push … 2>&1 | tail -5 reports tail's exit status, so a rejected push
reads as success. amont-agent
guards that end: a PreToolUse hook that refuses the command shape before it
runs. Independent of this verifier — related by author, and by the shared
conviction: trust what was verified, never what was reported.
What is in here
SPEC.md |
the amont-attest-v2 wire format, input fingerprints included |
action.yml |
the composite action, for GitHub and Forgejo: runs git-attest covered |
sign/ |
the CI producer: sign/action.yml runs git-attest sign |
RELEASE |
the git-attest release both actions run, and each archive's sha256 |
fetch.sh |
how the actions get that binary: cache or download, digest check, a proof it runs |
.github/attest-inputs |
this repository's own input spec: the dogfood, and a template |
verify.sh, sign/sign.sh |
the shell fallback the actions use when the binary cannot answer; deprecated as entry points, gone in 2.0.0 |
src/ |
the git_attest library, the one implementation of the format (decisions ADR-0037) that amont links, and git-attest, its command line |
tests/conformance.sh |
the fixtures both implementations must pass |
Forgejo
Same file, full-URL uses::
- uses: https://github.com/fredericrous/attest@v1
Other CI
git-attest explain is the answer to "why didn't it skip?", which is otherwise
unanswerable by design.
Zero dependencies
The verifier decides whether tests may be skipped, so it is a security
boundary, and everything it links is something you have to trust. It links
nothing. The hard parts — ed25519, the OpenSSH signature envelope — are handled
by ssh-keygen, which is already on every runner and audited by people who do
that for a living.
Contributing
tests/conformance.sh runs the fixtures against any implementation:
tests/legacy.sh is a negative control — the verifier this project
replaces, kept so the suite can prove it is not vacuous. Cases marked
(defect N) must fail against it. Please don't fix it. tests/compat/ holds
the 1.1.0 verifier, frozen, for the same reason: make compat proves a
one-block reader degrades safely on a multi-block note. tests/sign.sh is the
producer's suite, tests/fetch.sh the actions' way to the binary.
Releasing
The tagged commit must carry the digests of the archives it ships, and builds are not reproducible, so a release is built before it is tagged:
- A release PR bumps the version (
Cargo.toml,Cargo.lock,CHANGELOG.md). - Run
prepare-releaseon its branch (gh workflow run prepare-release.yaml --ref <branch>): it builds the six archives into a draft release and hands backRELEASE; commit it on the branch. - Merge on green and push the
vX.Y.Ztag on the merge commit.releasepublishes the draft only if its archives have the digests the taggedRELEASEnames, and publishes the crate. - Once amont's CI has passed on the new tag, run
move-major(gh workflow run move-major.yaml -f tag=vX.Y.Z) to movev1. Rollback is the same with the previous tag.
MIT.