CI minutes are billed; running the same matrix on GitHub-hosted runners after
a maintainer has already verified the change locally is duplicated spend. The
/local-attest skill lets a trusted user trade the local runtime of the
configured matrix — minutes to tens of minutes — for a clean skip of the
equivalent remote pipeline.
This guide is the operator contract for using the skill safely.
The gate input is a PR comment authored by a trusted user (default: the repo OWNER) whose first line is exactly the marker:
<!-- local-attest verified-sha=<full-head-sha> -->
<!-- local-attest-payload <base64url JSON> -->
The workflow gate (template at
workflow-gate.yml.tmpl) reads the PR comments,
filters by author_association, takes the first line of each, and
grep -qFxs (exact-line match) for the marker matching
github.event.pull_request.head.sha.
When the marker matches, downstream jobs whose if: consumes
needs.classify.outputs.attested skip at zero runner cost.
A new push changes the head SHA, the old marker no longer matches, and CI runs again. There is no auto-unlabel workflow; the label is decoration only.
GitHub Actions only re-evaluates workflow gates on push / synchronize
events, not on comment events. If you need to revoke an attestation, push
a new (even empty) commit — that’s the documented contract.
Some jobs should always run regardless of attestation:
if: produces a green check anyway. The
common shape is “always emit success from a downstream summary job, but
let the upstream attested jobs skip.”From the PR branch in your local checkout:
dotbabel local-attest --pr 123
The skill runs every leg of your .local-attest matrix, prints a result
table, posts the attestation comment and minimizes its own older ones,
pushes (if pushAfterAttest —
the comment goes first so the marker is visible when the push event fires),
applies the label, and appends a line to the audit log.
--dry-run runs the matrix and prints the comment without posting anything.
Use it to validate a new config end-to-end. (Combined with --only/--from
it is inert — diagnostic runs never post anyway.)
--no-push skips the git push step but still posts the comment + label.
Use it when you’ve pushed manually and just want to attest.
--fail-fast stops launching legs after the first hard failure; unstarted
legs are recorded not-run and a stopped run cannot attest. A --fail-fast
run in which nothing failed completed the full matrix and attests normally.
--only <leg> / --from <leg> are diagnostic modes for the fix-retry loop:
subsets under relaxed preconditions (dirty tree fine, no PR needed) that never
post, label, or push, and exit 1 on any selected-leg failure, advisory
included. restoreFiles snapshots and restores in diagnostic runs too.
Config-side execution controls (see config.md): lane groups
legs into concurrent lanes; when.changedPaths and skipWhenDiffOnly mark
legs skipped against the PR’s changed files (skipped legs still appear in
every table with status skipped, and an all-skipped run refuses to attest);
passPrBody injects the PR body as env.PR_BODY; restoreFiles snapshots
tracked files a leg overwrites and restores them before the head recheck.
The restore also runs on SIGINT/SIGTERM, so a Ctrl-C mid-matrix puts the
files back instead of leaving fixture stubs in the tree. That matters because
requireClean is checked before the snapshot is taken: a run killed between
seeding and restoring would otherwise leave every later run aborting on a dirty
tree it can no longer clean up. If that still happens (a SIGKILL, a power
loss), the precondition error appends the exact recovery command to the usual
“commit or stash” advice — git restore for tracked paths, git clean for
any the matrix created. It scopes both to the paths actually dirty, and only
fires when every dirty path is one restoreFiles manages: one unrelated
edit alongside the stubs and the message stays generic, because the check
cannot tell a leftover stub from your own work on the same file.
Default trustedAssociations: ["OWNER"]. Only comments from a user with
author_association == "OWNER" will gate CI. A non-trusted user’s comment
will post, but CI will still run.
Multi-maintainer repos widen the trust list:
// .local-attest.config.mjs
trustedAssociations: ["OWNER", "MEMBER", "COLLABORATOR"];
The workflow gate template is pre-substituted for the default single-OWNER
config. If you widen trustedAssociations, update the select(...) clause in
your workflow gate file and commit both changes together — they must stay in sync
or attestations from the newly-trusted association will be posted but never
honored by CI.
If branch protection requires a specific status check (e.g.
Test / backend tests) and you gate that job off via attestation, the check
will be reported as skipped, which counts as missing for protection.
Fix: introduce an always-run summary job that aggregates the attested jobs’ results and reports a single status check. Make that summary job the one required by branch protection. Example:
attested-or-passed:
needs: [test, preview] # jobs gated by local-attest
if: always()
runs-on: ubuntu-latest
steps:
- run: |
if [[ "$" == "skipped" && \
"$" == "skipped" ]]; then
echo "All CI jobs skipped via local attestation"
elif [[ "$" != "success" || \
"$" != "success" ]]; then
exit 1
fi
Point branch protection’s required status check at attested-or-passed instead
of the individual jobs.
The skill runs whatever your .local-attest.config.mjs says. If it drifts
from what .github/workflows/test.yml actually runs, the attestation
certifies a different (probably smaller) set of checks. Treat the config
file as documentation that has to track the workflow.
A simple drift check is to put both lists in a single source (e.g. a JSON manifest both sides import) — but most projects find it cheaper to review the diff manually whenever either side changes.
Attestation runs legs serially within a lane and lanes concurrently (a
config without lane fields is fully sequential), and costs whatever the
configured legs cost — minutes for a lint-and-unit matrix, tens of minutes with heavy
e2e and multiple language runtimes. That’s the deliberate price of skipping
the remote run; use --only/--from/--fail-fast for iteration and save
full runs for attestation.
The label ci/local-verified is decoration for visibility:
gh pr list --label ci/local-verified --state all
The audit log (.local-attest-log.jsonl by default) records one JSONL line
for every run whose matrix executes — failures included — tagged with a
result and per-leg statuses:
{
"ts": "2026-08-13T16:00:00.000Z",
"pr": 123,
"sha": "abc1234...",
"host": "wsl-laptop",
"advisoryFails": ["knip"],
"result": "attested",
"legs": [{ "name": "lint", "mode": "hard", "status": "pass", "durationS": 17 }],
"flags": { "only": [], "from": null, "failFast": false, "push": true, "dryRun": false },
"toolchain": { "node": "22.11.0" }
}
result is one of attested | hard-fail | head-moved | push-fail | post-fail
| dry-run | diagnostic; per-leg status is one of pass | fail |
advisory-fail | skipped | not-run. Lines written by versions before result existed
were only ever written after a successful post, so a missing result implies
attested. Diagnostic lines add dirty: true|false, because a dirty tree’s
sha does not identify the tree that ran.
Add the audit log to .gitignore — this is a requirement when
requireClean is on, not a preference. Every run appends to it, including
the --dry-run this guide recommends for validating a new config, so an
untracked log makes the very next attest abort on its own output. The
contract is single-line JSONL so any log shipper handles it natively.
The classic drift is matrix membership: a CI job with no local leg is
enforced nowhere once a PR attests. Diff rules add a second axis: a local
when/skipWhenDiffOnly glob narrower than the mirrored job’s paths:
filter skips the leg locally while the attestation disables the job remotely.
Nothing cross-checks the globs against .github/workflows/** — the config
author owns that mirror, and each diff-gated leg should cite the workflow
filter it mirrors in a comment so the pair is reviewable together.