git-attest 1.7.0

Sign and verify attestations that a test suite already passed on a tree, so CI can skip it: the library and its command line
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
# `amont-attest-v2`

A signed statement that a named set of checks passed against a specific git
**tree**, on a specific platform. It exists so CI can skip work a developer's
machine already did, without CI having to trust that machine.

This document is the contract. Two implementations in this repository —
`verify.sh` and `git-attest` — target it, and `tests/conformance.sh` is what
proves they agree. A third implementation should pass that suite.

## Why the name still says `amont`

The format was born inside [amont](https://github.com/fredericrous/amont),
which is still the only producer. The identifiers are **frozen**, not
aspirational: renaming them would orphan every note already signed, every
`allowed_signers` entry already pinned to the namespace, and every workflow
already deployed — to make a string prettier. Read `amont-` as a version
prefix that happens to be a word.

## The note

One note per attested object, in `refs/notes/amont-attest`. A note holds one
or more **blocks**, each a payload and its signature:

```
amont-attest-v2
tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
gates pre-push-cargo-test pre-push-clippy
platform aarch64-macos
amont 1.23.0

-----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg…
-----END SSH SIGNATURE-----
```

**Payload** — everything before the first blank line, **including its trailing
newline**. Those are the exact bytes the signature covers; a verifier that
drops the newline reconstructs bytes no signature will ever match. This is the
single most common way to get an implementation wrong.

| line | meaning |
|---|---|
| `amont-attest-v2` | format token, always first. An unrecognised value means **absent** — see below. |
| `tree <oid>` | the git tree the checks ran against |
| `gates <names…>` | space-separated names of checks that **passed**. Never empty in a valid note. |
| `platform <arch>-<os>` | where they ran, e.g. `x86_64-linux`, `aarch64-macos`, `x86_64-windows` |
| `input <gate> <fp>` | optional; see "Input fingerprints" |
| `canon <oid>` | optional; see "Canonical tree" |
| `amont <version>` | the producer version, informational |

`tree`, `gates` and `platform` are **required**. A v2 payload missing any of
them is not a valid note and covers nothing — `platform` included, even for a
verifier told to accept any platform: a note that does not say where it ran is
not evidence about anywhere.

Fields are read **by prefix, never by position**. The payload has already grown
a line once (v1 → v2 added `platform`), and a positional reader mis-assigns
every field after an insertion rather than failing. Where a field appears more
than once, the **first** occurrence wins and the rest are ignored.

**Signature** — an armored `ssh-keygen -Y sign` block, namespace
`amont-attest`, separated from the payload by a blank line.

### Several blocks in one note

A laptop attests a tree on `aarch64-macos`; the CI job that tests the same
tree attests it on `x86_64-linux`. One note per object, so both statements
live in the same note, as blocks separated by one or more blank lines:

```
amont-attest-v2
tree 4b825dc6…
gates pre-push-cargo-test
platform aarch64-macos
amont 1.34.0

-----BEGIN SSH SIGNATURE-----
…
-----END SSH SIGNATURE-----

amont-attest-v2
tree 4b825dc6…
gates pre-push-cargo-test ci-fmt
platform x86_64-linux
amont attest-sign

-----BEGIN SSH SIGNATURE-----
…
-----END SSH SIGNATURE-----
```

That is exactly the shape `git notes append -m <block>` produces, and its
stripspace pass (trailing whitespace stripped, runs of blank lines collapsed)
leaves it unchanged — the grammar is that command's fixed point, so a
producer needs no flags. The parse, which both implementations perform with
the same state machine:

1. Skip blank lines. A payload starts at the first non-blank line and runs to
   the next blank line; its trailing newline is part of the signed bytes.
2. Skip blank lines. The next line must be exactly
   `-----BEGIN SSH SIGNATURE-----`. If it is not, **parsing stops**; blocks
   already collected still count.
3. The signature runs to and including `-----END SSH SIGNATURE-----`. End of
   input closes an open signature. A block that never ends swallows whatever
   follows, and then fails to verify.
4. At most **32 blocks** are read; any beyond are ignored, and the verifier
   says so. Every block costs two `ssh-keygen` runs, and the notes ref is
   writable by anyone with push access.

Lines are LF-separated, and a note containing a carriage return **anywhere**
is rejected whole, before parsing. This is a check on the raw bytes, made by
each implementation itself rather than by a tool it shells out to: the `awk`
and `sed` of some environments drop carriage returns on input, and a CRLF note
must cover nothing everywhere.

Each block is judged **on its own** by every rule under "Verifying". The
isolation guarantee, precisely: a correctly framed block whose payload or
signature fails any check does not affect the other blocks; a framing error
(a missing BEGIN, a missing END) ends parsing where it occurs. The verifier
reports the **union** of the gates of the blocks that pass, in first-appearance
order, without duplicates. A note reached through more than one candidate
object (a producer that wrote it to the tree and the commit) is judged once.

**Compatibility.** The 1.1.0 verifiers read one block: the payload before the
first blank line and the signature from the first BEGIN marker to the end of
the note, of which OpenSSH parses up to the first END marker. On a multi-block
note they therefore report block 1's gates, or nothing. They can under-report,
which is safe; they can never report a gate that only a later block names.
`tests/compat.sh` proves this against the frozen 1.1.0 implementations.

## Which object carries the note

**The tree.** The signature covers a tree because tests read content, not
commit messages — so a reword, an amend, a rebase or a forge's squash-merge all
preserve the attestation, and each of those would break a commit-keyed one.

Verifiers should also look at `HEAD` and then `HEAD^2`, in that order after the
tree, for notes written by producers that keyed by commit only. `HEAD^2` is
there because a pull-request checkout is a merge commit the forge made a moment
ago, whose second parent is the pushed tip.

A note found that way is still judged by its `tree` line, and on a
pull-request checkout the tree of that merge commit equals the pushed tip's
tree only when the branch is up to date with its base. Otherwise the
attestation does not apply — correctly, since the merged result was never
tested — and the verifier says so. A `push` trigger sees the pushed tip
itself and has no such gap.

## Verifying

A verifier reports a gate of a block as covered when **all** of these hold.
Anything else, including any error, reports nothing covered:

1. the payload's first line is exactly `amont-attest-v2`;
2. `gates` is non-empty and names the gate;
3. `ssh-keygen -Y verify -n amont-attest` succeeds against an `allowed_signers`
   file **committed in the consuming repository**, for a principal that file
   names;
4. the gate qualifies by tree **or** by fingerprint: `tree` equals the tree
   actually checked out (`git rev-parse 'HEAD^{tree}'`), or the block carries
   `input <gate> <fp>` and `<fp>` equals the gate's fingerprint on the
   checked-out tree (see "Input fingerprints");
5. `platform` matches what the caller asked for (see below).

Rule 3 comes before rule 4 on purpose: a fingerprint, like the `anywhere`
list, is a claim inside the payload, and only a verified payload may make
one.

```
you@example.com namespaces="amont-attest" ssh-ed25519 AAAAC3Nza…
```

### Platform

| the caller passes | an attestation is accepted from |
|---|---|
| nothing | this machine's `<arch>-<os>`, exactly |
| `<arch>-<os>` (contains a dash) | that string, exactly |
| `<os>` (no dash) | any `<arch>-<os>` whose part after the **last** dash is `<os>` — never a substring, so `inux` matches nothing and `x86_64` does not match `x86_64-linux` |
| `any` | anywhere |

`<arch>` and `<os>` are the names Rust's `std::env::consts` uses (`x86_64`,
`aarch64`, `arm`, `powerpc64`; `linux`, `macos`, `windows`, `freebsd`,
`android`, `illumos`, …). A shell implementation maps `uname` onto them, and
a machine it names differently can only lose a skip, never gain one: the
names must be equal to match.

A pass is a pass on something: a macOS `cargo test` is no evidence about the
Windows leg of a matrix. `any` is the caller's statement that the whole suite's
result cannot depend on where it ran.

**Gates accepted from anywhere.** Some checks cannot depend on where they ran:
formatting, shell lint, secret scanning, dependency audit. A verifier may take
a list of gate names the caller accepts from any platform (`--anywhere`, the
action's `anywhere` input); those names are admitted from a verified block on
any platform, and other names in the same block are still held to the table
above. The list is the caller's committed statement about those checks, made
once. It is applied strictly **after** signature verification: an unverified
block contributes nothing, listed or not. Never list a check that compiles or
executes the product — tests, clippy, a typecheck under `cfg`, a build —
whatever its name says.

### Principal

`-Y verify` demands a principal, and the right one is read from the signature
rather than guessed: `ssh-keygen -Y find-principals -s <sig> -f
<allowed_signers>` names the principal whose key made the signature, if that
key is in the file, and that is the identity to verify as. A verifier may let
its caller narrow this to one identity; it must not default to a guess such as
the file's first entry, which silently uncovers every other signer.

Two things pin the namespace. The `-n amont-attest` on `verify` rejects a
signature whose embedded namespace is anything else, which is what stops a
signature minted for another purpose — a signed commit, say — being replayed
here. The `namespaces="amont-attest"` option in the file works the other way:
it stops this key being trusted for anything else, should the same file ever
be reused as `gpg.ssh.allowedSignersFile`.

That file is read from the tree being verified, so **a change to it is a
change to who may skip CI**. Anyone who can push a branch and the notes ref
can add a key there and attest the same tree with it. That is the trust level
of editing a workflow file, no more — but repositories that guard
`.github/workflows/` with CODEOWNERS or required review should guard
`allowed_signers` the same way — and `attest-inputs` too, once it exists
(see "Input fingerprints").

### Input fingerprints

An attestation keyed by the root tree dies with any change anywhere: a docs
commit on main, an unrelated package in a monorepo, the pull-request merge
commit whenever base moved. A gate's result depends on the files it reads, so
a repository may declare those files once, and a gate is then covered on any
tree whose declared inputs are **byte-identical** to the attested ones.

**The spec.** `.forgejo/attest-inputs`, then `.github/attest-inputs`, read from
the tree being verified or signed (`git cat-file blob <tree>:<path>`), never
from the working copy. Missing means "no fingerprint route", silently.
Unreadable or invalid disables the route and is reported. Both present
disables the route ("ambiguous"). The grammar, checked on the raw bytes
before anything is parsed, and identical in both implementations:

- **`git ls-tree` does not glob.** Paths are literal files or directories,
  relative to the root; `tests/*.sh` would match nothing and silently
  fingerprint nothing, so it is refused.
- Any byte outside printable ASCII (0x21–0x7E), space, tab and LF — a NUL, a
  CR, a control byte, any non-ASCII byte, which includes every Unicode
  whitespace — invalidates the spec. More than 65 536 bytes invalidates it.
  A path with other bytes is covered by naming an ASCII parent directory.
- A line is bytes up to LF; a final line without LF is a line. A line whose
  first non-blank byte is `#` is a comment; blank lines are ignored; `#` is
  not a mid-line comment.
- Tokens split on runs of space and tab: `<gate> <path>...`. A gate name
  matches `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`. At least one path; a duplicate
  gate; more than 64 gates or 64 paths per gate — each invalidates.
- A path is invalid if it starts with `:`, `/`, `./` or `../`, ends with `/`,
  contains `*`, `?`, `[`, `]` or `\`, or has an empty, `.` or `..`
  component. Spaces are unrepresentable.
- **Optional paths** (1.4.0). One leading `?` marks a path that may be
  absent: `?build.rs`, `?.cargo`, `?clippy.toml`. The marker is not part of
  the path; the rest obeys every rule above, so `?`, `??x` and `?/x` are
  invalid. A required path must exist; an optional one is listed when it
  exists and bound by its absence when it does not — the day it appears, the
  listing and so the fingerprint change. This is how a gate says "nothing
  here yet, and it matters if there is": a config file that changes a tool's
  verdict, a build script, a directory the build tool discovers by itself.
- **The directive** (1.6.0). A line whose first token starts with `@` is a
  directive, never a gate. The only one is `@plan-exempt off` (blanks around
  and between the two words; see "Canonical tree", "Opt-out"); any other is
  invalid. A spec may hold the directive alone and declare no gate.
- **Any violation invalidates the whole spec**, never one line: a line that
  silently dropped out would be one the author believes is protecting
  something.

**The fingerprint** of gate `g` on tree `T`, computed from the repository
root:

```
B  = git ls-tree -r -z --full-tree T -- \
       .forgejo/attest-inputs .github/attest-inputs .gitmodules \
       <.gitattributes at the root and at every ancestor directory of every
        declared path of g, deduplicated> \
       <g's declared paths, in spec order>
fp = git hash-object --stdin < B         (40 hex; 64 in a sha256 repository)
```

Preconditions, checked identically by producer and verifier: the spec is
valid, and every REQUIRED declared path of `g` resolves on `T` (one `git
cat-file --batch-check` per gate; a `missing` answer disqualifies the gate).
Optional paths are not checked; they appear in the `ls-tree` arguments, and
in the ancestor `.gitattributes` above, with the marker stripped. Both
processes must succeed: if `ls-tree` exits non-zero for any reason, including
after emitting records, `g` has no fingerprint on `T`. An empty listing is
refused. Both spec locations are always listed, so adding, removing or
editing either invalidates; the spec's own blob is in the listing, so a spec
change invalidates; ancestor `.gitattributes` and `.gitmodules` are listed
because they change what a checkout yields without changing any listed blob.
The argument order does not affect the value; `hash-object --stdin` applies
no filters. A drift in git's output on one side can only lose a skip, never
grant one.

What a fingerprint binds is committed bytes, modes and attributes. It does
not bind external filter configuration, tool versions, or the environment;
those are the platform line's and the gate name's business. A symlink that
points outside the declared set, and a spec that omits something a gate
reads, are the consumer's declaration problem — the same class as the
`anywhere` list. A gitlink is bound by its pointer — declare the submodule's
parent directory for that, because the submodule path itself answers
`missing` to `cat-file` and gets no fingerprint; a path inside a submodule
matches nothing and fails closed.

**The payload line.** `input <gate> <fp>`: exactly three blank-separated
fields, one line per fingerprinted gate, after `platform` and before
`amont`, in spec order. A reader accepts only exactly three fields and an
oid of 40 or 64 lowercase hex digits; the first occurrence per gate wins.
Producers before 1.3.0 write no such line; verifiers before 1.3.0 ignore it.

**The synthetic key.** `K(g, fp)` is `git hash-object --stdin` over exactly
the bytes `amont-attest-input <g> <fp>\n`. Producers attach the block under
`K` in a **second notes ref, `refs/notes/amont-attest-inputs`**; a verifier
computes `fp` on the checked-out tree and looks `K` up there. Every key in
that ref is an oid that is not an object — `git notes` accepts that, and
`git notes list` shows it, by design. **Never run `git notes prune` on that
ref**: it drops every note there. Never merge either ref with `cat_sort_uniq`:
it sorts lines and destroys block framing (true since 1.2.0). The main ref
keeps its invariant that every key is a real object.

**Lookup.** Candidates are `HEAD^{tree}`, `HEAD`, `HEAD^2` in the main ref,
then — lazily, only for gates the spec declares and nothing above covered —
`K(g, fp)` in the inputs ref. For each candidate, in that order, the refs
are read in the order "Which refs are read" gives: origin's mirror, then the
unpushed blocks when asked. Every block found anywhere is judged by the
same rules; a block's verdict depends only on its content and the checked-out
tree, so a block already judged is skipped wherever it appears again (by its
exact bytes; a note already read is skipped by its oid). A verifier verifies
at most 64 signatures per invocation over all candidates — one verification
being one call of its verify routine — and says so when the budget is spent.
Because the budget is shared, the visit order is part of the contract.

### Canonical tree

Input fingerprints need a declaration in every repository. The one
declaration nearly every repository would make is "everything except what a
branch's plan commits touch", so since 1.5.0 that is computed from the tree
itself, with nothing to declare and nothing to keep complete.

**Definition.** The canonical form of tree `T` is the listing of
`git -c core.quotePath=true ls-tree -r --full-tree T` (paths quoted, so one
record is one line, ending in LF) with one change: in every record whose
mode is `100644` or `100755` and whose path starts with the top-level
`docs/plans/`, the object id is replaced by zeros of the same length (40 or
64). Mode and type are kept, so the **existence** of a plan file counts and
its **content** does not. Symlink (`120000`) and gitlink (`160000`) records
stay byte for byte. A nested `x/docs/plans/` and a file or directory merely
named `docs/plans-…` are not exempt. A quoted path is recognised by its
unescaped prefix `"docs/plans/`. `canon(T)` is `git hash-object --stdin` of
that text. If `ls-tree` exits non-zero, writes nothing, or writes a record that
is not `<mode> <type> <oid>TAB<path>`, there is no `canon(T)`.

Editing a plan leaves `canon` unchanged; adding, deleting or renaming a plan
file, or changing any other path, changes it.

**Known answers.** `tests/vectors/canon.txt` holds raw listings with the
canonical text each must give; the library and both shell scripts read that
one file, and amont, which links the library, keeps no copy. canon(T) is not
the content id of `amont-provenance-v1` (amont's `docs/provenance.md`), which
ignores whether a plan exists.

**Opt-out.** A repository whose gate reads the contents of a plan writes
`@plan-exempt off` in its `attest-inputs` (1.6.0). Every producer and verifier
reads one policy, `plan_exempt_off(T)`, from the committed bytes of `T`. It is
off when either holds:

- a line of `T:.forgejo/attest-inputs` or `T:.github/attest-inputs` is the
  directive: blanks around and between the words `@plan-exempt` and `off`,
  nothing else. It is found even in a spec that is otherwise invalid, since
  off only narrows;
- a line of `T:amont.conf` says `set planExempt off`: strip CR, trim blanks,
  split on blanks, compare the three words ASCII case-insensitively; a line
  that is not exactly those three words does not match. This form is
  **deprecated** and read until 2.0.0; a verifier that finds it alone says
  `deprecated: planExempt in amont.conf; also write @plan-exempt off in
  attest-inputs`.

It fails narrow: an `ls-tree` that fails, or a listed file that cannot be
read, is off; only successful listings and readings that hold neither form
are on. When off, a producer writes no `canon` line and no canonical key, and
a verifier ignores canonical equality in every block, whichever key found it.

**Until 2.0.0, write both.** A verifier or producer before 1.6.0 reads the
directive as an invalid gate name: that disables its input fingerprints, but
not its canonical route. A repository that opts out therefore commits both
`@plan-exempt off` and `set planExempt off` until no consumer older than 1.6.0
remains (decisions ADR-0037). A repository whose `attest-inputs` declares
gates gains the directive only once its own consumers are at 1.6.0 or later,
since to older ones the directive invalidates those gates' fingerprints.

**The payload line.** `canon <oid>`: exactly two blank-separated fields and
an oid of 40 or 64 lowercase hex digits, after the `input` lines and before
`amont`; the first occurrence wins. Verifiers before 1.5.0 ignore it (fields
are read by prefix), so an old verifier only loses a skip.

**The key.** `Kc(c)` is `git hash-object --stdin` over exactly the bytes
`amont-attest-canon <c>\n`, in the same second ref, `refs/notes/amont-attest-inputs`;
there is no third ref to mirror.

**The rule.** A gate of a verified block is covered when the block's tree is
the checked-out tree, **or** the block's `canon` equals `canon` of the
checked-out tree (every gate of the block), **or** its input fingerprint for
that gate equals the one computed here. The canonical tree is asked of the
checked-out tree, so a block filed under several keys is judged the same
wherever it is first met. Signature, principal, platform and `anywhere` rules
apply unchanged, after the signature verifies. A gate covered this way is
reported as `(by canonical tree)`, and the canonical fingerprint and `Kc`
are named once.

**Lookup.** After the object-keyed candidates and the input-fingerprint keys,
one more: `Kc` in the inputs ref, in the order "Which refs are read" gives. It
shares the 64-verification budget, last in the visit order.

**What it does not bind.** A gate that reads the contents of a plan (a
formatter or linter over the whole tree, a link checker) would be skipped on a
plans-only change; such a repository opts out. Existence is bound, so a check
that a cited plan exists is re-run when one is added, deleted or renamed.

### Which refs are read

Origin is the only place an attestation can be revoked — by deleting or
rewriting its notes refs — so a verifier treats
`refs/notes/amont-attest` and `refs/notes/amont-attest-inputs` in the
repository it runs in as **origin's mirror**, never as a store of its own
(1.4.0). It fetches both refs in one call, and:

| outcome | `notes=` | the local ref |
|---|---|---|
| the fetch succeeded | `fetched` | is origin's ref, and is judged |
| origin answered and has no such ref | `absent` | is **deleted**, with a line saying how to restore it; nothing judged |
| as above, but the delete failed (a read-only `.git`) | `undeletable` | is not judged |
| origin is configured and did not answer (or the deadline cut it off) | `unreachable` | is **not judged**: a stale mirror on a persistent runner must not outlive a revocation |
| origin's copy was fetched but cannot become the local ref (a stale lock) | `unwritable` | is not judged; the reason names the lock and the `rm` that clears it |
| there is no remote named `origin` | `no-origin` | is judged as it is — for fixtures and local use; CI always has an origin |

**The fetch lands in a throwaway ref** (1.4.1), outside `refs/notes/` —
`refs/attest-tmp/<pid>/<ref>` — and becomes the mirror by a local
compare-and-swap against what the mirror held before the fetch. The deadline
kills git, and a fetch killed while writing the mirror itself would leave
`<ref>.lock` behind, failing every later update of it; a kill can now only
half-write the throwaway. When the swap fails, the mirror is judged only if
it holds EXACTLY the oid just fetched (a concurrent run got there first, or
origin is unchanged under a lock); anything else is `unwritable`. Throwaways
of runs that were killed before their cleanup are removed by the next run,
but only those whose process is gone (`kill -0` answers "No such process");
a live run's is left alone. A lock left on a mirror by an older version is
reported with the command that clears it.

"Did not answer" is decided with at most two remote calls: the fetch of both
refs and, when it fails without timing out, one `git ls-remote --exit-code
origin` naming both. A fetch the deadline cut off is not followed by
`ls-remote` — origin already did not answer. Exit 0 lists the refs that exist
(an unlisted one is absent; the listed ones are fetched once more, and a
failure there is `unreachable`); exit 2 means neither exists; anything else is
`unreachable` for both. Every remote call runs without prompts: stdin closed,
`GIT_TERMINAL_PROMPT=0`, and — because git runs `GIT_ASKPASS`,
`core.askPass` and `SSH_ASKPASS` BEFORE it consults that variable — a
`GIT_ASKPASS` that is present and EMPTY, which makes it run none of them;
credential managers are told not to interact (`GCM_INTERACTIVE=never`,
`credential.interactive=never`) while their stored credentials still work;
ssh runs in batch mode with a 10 s connect timeout unless the user
configured an ssh command through `GIT_SSH_COMMAND`, `GIT_SSH` or
`core.sshCommand` (that command may prompt on the terminal; the deadline
bounds the wait). Then curl's low-speed limit of 10 s, and a 15 s deadline of
the verifier's own: a verifier that waits for a password or a dead host has
broken "exit 0" as surely as a crash. A silent origin costs one call, at
most 15 s; one that fails fast, two, at most 30 s; the worst case, an origin
that answers `ls-remote` but stalls the second fetch, three, at most 45 s.

**Unpushed blocks** — written by a producer that did not push, such as
`sign.sh --no-push` — live in `refs/notes/attest-local/amont-attest` and
`refs/notes/attest-local/amont-attest-inputs`. Origin never saw them and
cannot revoke them, so a verifier reads them **only when asked**
(`--include-local`), after the mirror, whether or not the mirror was judged,
and names them `(unpushed)` in its reasons. `git update-ref -d
refs/notes/attest-local/…` discards them. A blanket `git push origin
'refs/notes/*'` would publish them; push the two refs by name instead.

**Compatibility.** `input` lines are additive; the format token stays
`amont-attest-v2`. A 1.3.x reader — `verify.sh` and git-attest 1.3.x, and
amont's producer until it learns the marker — rejects a spec that uses `?` as
a wildcard, so the whole spec is invalid there: it writes and accepts no
fingerprint skips, and falls back to the tree. It can lose skips; it can
never gain one. `tests/compat-fields.sh` proves it against the frozen 1.3.1
implementations. A 1.1.0 or 1.2.0 verifier reads a block that carries
them exactly as before, by tree, and never reaches a synthetic key.
`tests/compat-fields.sh` proves this against the frozen 1.1.0 and 1.2.0
implementations.

**Trust.** The spec is read from the tree being verified, so it is a trust
boundary like `allowed_signers`: a pull request can shrink a gate's declared
inputs. That PR gets no skips itself, because the spec's oid is in every
fingerprint, but every later PR that avoids the declared paths does. That is
the two-step trust of editing a workflow file; guard `attest-inputs` the way
`allowed_signers` is guarded. (`allowed_signers` itself is read from disk,
the spec from the tree — the inconsistency is noted rather than deepened.)

Tightening either file does not retire what was attested under the old one.
Both are read from the tree being verified, so a pull request that puts the
old spec (or an old signer) back, with inputs matching an old attestation,
gets that attestation's skips: fixing an under-declared spec closes the gap
for new trees only. The notes refs on origin are the only place an
attestation can be withdrawn.

### Revoking

- **Every fingerprint and canonical skip at once:** `git push origin
  :refs/notes/amont-attest-inputs`. A 1.4.0 verifier deletes its mirror on
  its next run and prints how to restore it; nothing is covered by
  fingerprint until producers sign again. Keep the old oid first —
  `git ls-remote origin refs/notes/amont-attest-inputs` — and `git push
  origin <oid>:refs/notes/amont-attest-inputs` restores it.
- **Tree-keyed attestations too:** the same for `refs/notes/amont-attest`.
  Since 1.5.0 a producer files its block under `Kc` in the inputs ref as well,
  so deleting the tree ref alone leaves a plans-only skip standing: delete
  both to revoke everything.
- **One gate:** rename it — in the spec, the hook and the workflow. No note
  names the new gate, so nothing old covers it.
- **A signer:** remove the key from `allowed_signers` and revoke the refs as
  above; removal alone does not stop a pull request that restores the key.

**Order matters.** Move every producer to 1.4.0, or delete its local
`refs/notes/amont-attest[-inputs]`, before revoking: a 1.3.x `sign.sh`
builds its push from origin's ref plus its whole local ref, and so
re-publishes what was revoked. Two readers do not honour a revocation yet:
amont's own verifier (`amont attest covered`), whose fetch never deletes a
local ref, and amont's producer, which also does not know the `?` marker —
both are an amont follow-up.

### Fail-open is the contract

Every failure — no note, no signers file, no `ssh-keygen`, a tree that moved, a
signature that does not verify, an unknown format version, an origin that did
not answer — means **not covered**, which means the caller runs its tests. A verifier must **exit 0**
regardless.

This is deliberate and it is the whole safety argument: nothing in this format
can cause an untested tree to skip CI. It can only cost a redundant run. A
verifier that could fail a build would have turned a CI accelerator into a CI
dependency.

The cost of that choice is **silence** — when a skip does not happen, nothing
says why. Implementations must therefore report their reasoning on stderr
(`verify.sh` does so by default; `git-attest explain` prints the trail).

### Two things a verifier must not do

- **Do not match gate names as substrings.** `pre-push-cargo-test-slow` is not
  `pre-push-cargo-test`. Emit gates as a list and compare elements; on GitHub
  and Forgejo that means `contains(fromJSON(outputs.gates || '[]'), 'name')`, never
  `contains(outputs.covered, 'name')`.
- **Do not resolve `allowed_signers` relative to the working directory.**
  Resolve it from the repository root, or a job that sets `working-directory`
  finds no signers, covers nothing, and fails open forever with CI still green.

## Producing

amont's pre-push hook is the reference producer for a laptop; `sign/sign.sh`
in this repository (the `fredericrous/attest/sign` action) is the reference
producer for CI. What a producer owes this format:

- Sign only checks that actually **passed** — not warned, not skipped, not
  unavailable. A CI producer signs only gates whose steps ran and passed in
  that job: never under `if: always()`, and never a gate that was skipped
  because it was already attested — that would launder another platform's
  result into this one.
- Run them against **the tree being signed**. A pre-push hook runs in a
  working directory that may carry uncommitted or untracked changes, and a
  suite that passed on that is no evidence about `HEAD^{tree}`. Refuse to
  sign while the working tree has modified or non-ignored untracked files, or
  check out a clean export of the tree. The `tree` line is the tree of the
  object the note is attached to — `HEAD^{tree}` by default — and a producer
  must not sign any other. On a `pull_request` checkout that tree is the
  forge's merge tree.
- Name the platform honestly, and key the note to the tree.
- **Fingerprints, when the tree declares them.** Read the spec from the tree
  being signed; for each gate being attested that the spec declares and whose
  paths all resolve on that tree, compute the fingerprint exactly as above,
  add its `input` line, and append the block under `K(gate, fp)` in the
  inputs ref as well as under the object in the main ref. Publish the two
  refs independently, and on a later run repair whichever keys are missing
  the block. An invalid spec degrades to the object-keyed block.
- **Append, never replace.** Write the block with `git notes append`; `git
  notes add -f` erases every other producer's block on that tree. Build what
  you push from origin's current ref plus your block, never from the local
  ref: that would re-publish a block origin revoked. A block you do not push
  goes to `refs/notes/attest-local/…`, not to the mirror. Keep the
  `amont <version>` line stable across runs — no run id, no timestamp — so a
  re-run recognises its own block as already present instead of appending it
  again. Use an ed25519 key: its signatures are deterministic, which is what
  makes that recognition exact.
- Keep the **gate name** an honest contract. It is the only thing tying the
  hook's command to the CI step that trusts it: a gate called
  `pre-push-cargo-test` that ran `cargo test --lib` covers a CI step running
  `cargo test --workspace` in name only. Same name, same command.

## `amont-provenance-v1`

A second, separate note that amont signs with the same key (decisions
ADR-0036): what a push carried besides its tests. It is **not** an
attestation, no verifier here reads it, and its namespace `amont-provenance`
keeps a provenance block from ever verifying as one. Since 1.7.0 it is
specified where it is produced, in amont's
[`docs/provenance.md`](https://github.com/fredericrous/amont/blob/main/docs/provenance.md),
the content id included.