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
name: ci

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 6 * * 1" # the pinned toolchain means a red build is attributable
  workflow_dispatch:

permissions:
  contents: read

# This repository eats its own cooking: every job that runs gates verifies
# first and signs last, so the push to main after a merge, and every re-run,
# skips what the pull request already proved.
#
# Except on `schedule` and `workflow_dispatch`, which exist to catch what no
# fingerprint binds — a new runner image, shellcheck, git, ssh-keygen — and
# so must never skip. The attest step does not run there; its output is then
# empty, which `|| '[]'` turns into "nothing covered" (fromJSON('') would
# fail the job). `contents: write` on those jobs
# is for pushing refs/notes/amont-attest and nothing else; a fork PR gets no
# secret and a read-only token, and the sign step reports and succeeds.
jobs:
  lint:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - id: attest
        if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
        uses: ./
        with:
          # fmt and shellcheck read text; clippy compiles under cfg and is
          # NOT portable, so it stays on the strict platform match.
          anywhere: ci-fmt ci-shellcheck
      - id: fmt
        if: ${{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), 'ci-fmt') }}
        run: cargo fmt --all -- --check
      - id: clippy
        if: ${{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), 'ci-clippy') }}
        run: cargo clippy --all-targets -- -D warnings
      - id: shellcheck
        if: ${{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), 'ci-shellcheck') }}
        run: shellcheck verify.sh sign/sign.sh fetch.sh tests/*.sh tests/fault/git tests/fake/* scripts/*.sh
      # Never behind an attest skip: cheap, and a drift they catch would
      # otherwise ride on a fingerprint that does not name them.
      - name: every flag is in its --help
        run: ./scripts/help-lists-every-flag.sh
      - name: the three platform tables are identical
        run: ./scripts/platform-tables-match.sh
      - uses: ./sign
        with:
          key: ${{ secrets.ATTEST_SIGNING_KEY }}
          # ONLY what ran here. A step skipped because it was already attested
          # has outcome == 'skipped', and re-signing it would launder someone
          # else's platform into ours.
          gates: >-
            ${{ steps.fmt.outcome        == 'success' && 'ci-fmt'        || '' }}
            ${{ steps.clippy.outcome     == 'success' && 'ci-clippy'     || '' }}
            ${{ steps.shellcheck.outcome == 'success' && 'ci-shellcheck' || '' }}

  # The fixtures, against BOTH implementations, on every platform the format
  # names. macOS matters specifically: it is the platform most attestations are
  # produced on, and the one where `bash` may be 3.2, and where `mktemp` is
  # BSD's. Windows because the format names it and the release ships it. The
  # three legs then sign concurrently into one notes ref, which is the retry
  # loop under real contention.
  conformance:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        # The verifiers' fixtures and the producers' are separate legs: since
        # 1.6.0 sign-test runs both signers against both verifiers, and on
        # Windows, which spawns processes slowly, one leg running both suites
        # passed 40 minutes. Split, each finishes in about half that, in
        # parallel.
        suite: [conformance, sign-test]
    runs-on: ${{ matrix.os }}
    timeout-minutes: 40
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - id: attest
        if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
        uses: ./
      - id: test
        if: ${{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), 'pre-push-cargo-test') }}
        run: cargo test
        shell: bash
      - id: conformance
        if: ${{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), format('ci-{0}', matrix.suite)) }}
        run: make ${{ matrix.suite }}
        shell: bash
      # The way to the pinned binary, on every runner it serves. Seconds, and
      # never skipped: what it checks (curl, tar, sha256sum, the RELEASE
      # file) is no gate's input.
      - name: fetch.sh
        if: ${{ matrix.suite == 'sign-test' }}
        run: make fetch-test
        shell: bash
      - uses: ./sign
        with:
          key: ${{ secrets.ATTEST_SIGNING_KEY }}
          gates: >-
            ${{ steps.test.outcome        == 'success' && 'pre-push-cargo-test' || '' }}
            ${{ steps.conformance.outcome == 'success' && format('ci-{0}', matrix.suite) || '' }}

  # A 1.1.0 verifier must read block 1 of a multi-block note and nothing
  # else. This is the proof behind SPEC.md's compatibility claim.
  compat:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - run: make compat

  # Conformance drives verify.sh directly and therefore proves nothing about
  # the actions wrapped around it — inputs, $GITHUB_OUTPUT, output plumbing.
  # This job is the only thing that does. It is ISOLATED from the real notes
  # ref before its first verification: origin is pointed at an empty bare
  # repository, so what the repository's own producers attested cannot leak
  # into the assertions — and since the local notes ref is origin's mirror,
  # every note this job writes by hand is pushed there. Fixture files live
  # under $RUNNER_TEMP, never in the checkout, so the producer's clean-tree
  # guard holds.
  #
  # Four legs, one per way the actions reach an answer, each held to the
  # same assertions: `built`, this branch's binary through `binary:`;
  # `release`, the binary RELEASE pins, downloaded and digest-checked;
  # `broken` (exits non-zero) and `silent` (exits 0, writes nothing), where
  # every step must warn and fall back to verify.sh / sign.sh.
  action:
    strategy:
      fail-fast: false
      matrix:
        binary: [built, release, broken, silent]
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - id: bin
        name: the binary for this leg
        env:
          LEG: ${{ matrix.binary }}
        run: |
          set -eu
          # Under target/, which is ignored: the producer's clean-tree guard
          # holds.
          case "$LEG" in
            built) cargo build --release -q; path=target/release/git-attest ;;
            release) path= ;;
            broken) mkdir -p target/stubs; printf '#!/bin/sh\nexit 3\n' > target/stubs/git-attest; path=target/stubs/git-attest ;;
            silent) mkdir -p target/stubs; printf '#!/bin/sh\nexit 0\n' > target/stubs/git-attest; path=target/stubs/git-attest ;;
          esac
          [ -z "$path" ] || chmod +x "$path"
          echo "path=$path" >> "$GITHUB_OUTPUT"
      - name: isolate from the real attestations
        run: |
          set -eu
          git init -q --bare "$RUNNER_TEMP/origin.git"
          git remote set-url origin "$RUNNER_TEMP/origin.git"
          git update-ref -d refs/notes/amont-attest || true
          # `git notes add` writes an object and needs a committer identity;
          # a bare runner has none.
          git config user.email ci@example.org
          git config user.name CI

      # 1. Fail-open, which is the property that matters most: no attestation
      #    means nothing covered, and the step must not fail the job.
      - id: bare
        uses: ./
        with:
          binary: ${{ steps.bin.outputs.path }}
      - name: nothing covered, and the step succeeded
        run: |
          set -eu
          [ "${{ steps.bare.outputs.gates }}" = "[]" ] || {
            echo "expected [], got: ${{ steps.bare.outputs.gates }}"; exit 1; }
          [ -z "${{ steps.bare.outputs.covered }}" ] || {
            echo "expected empty covered"; exit 1; }
          [ '${{ steps.bare.outputs.notes }}' = absent ] || {
            echo 'expected notes=absent, got ${{ steps.bare.outputs.notes }}'; exit 1; }

      # 2. Mint a real attestation over the tree this job checked out, and
      #    prove the action finds, verifies and reports it.
      - name: sign this exact tree by hand
        run: |
          set -eu
          ssh-keygen -q -t ed25519 -N '' -C ci@example.org -f "$RUNNER_TEMP/k"
          printf '%s namespaces="amont-attest" %s\n' ci@example.org "$(cat "$RUNNER_TEMP/k.pub")" \
            > "$RUNNER_TEMP/ci-signers"
          tree=$(git rev-parse 'HEAD^{tree}')
          printf 'amont-attest-v2\ntree %s\ngates pre-push-cargo-test pre-push-clippy\nplatform x86_64-linux\namont ci\n' \
            "$tree" > "$RUNNER_TEMP/p"
          ssh-keygen -Y sign -n amont-attest -f "$RUNNER_TEMP/k" "$RUNNER_TEMP/p"
          git notes --ref amont-attest add -f \
            -m "$(printf '%s\n\n%s' "$(cat "$RUNNER_TEMP/p")" "$(cat "$RUNNER_TEMP/p.sig")")" "$tree"
          git push -q -f origin refs/notes/amont-attest:refs/notes/amont-attest
      - id: signed
        uses: ./
        with:
          binary: ${{ steps.bin.outputs.path }}
          signers: ${{ runner.temp }}/ci-signers
      - name: the attestation is reported, exactly
        run: |
          set -eu
          got='${{ steps.signed.outputs.gates }}'
          want='["pre-push-cargo-test","pre-push-clippy"]'
          [ "$got" = "$want" ] || { echo "want $want, got $got"; exit 1; }

      # 3. The skip condition readers will copy, both directions.
      - name: a covered gate is skipped
        if: ${{ contains(fromJSON(steps.signed.outputs.gates), 'pre-push-cargo-test') }}
        run: echo "correctly identified as covered"
      - name: an uncovered gate still runs
        if: ${{ !contains(fromJSON(steps.signed.outputs.gates), 'pre-push-audit-rust') }}
        run: echo "correctly identified as NOT covered"

      # 4. The sign action: no key is a clean no-op; a real key appends a block
      #    on another platform beside the hand-written one, and the verifier
      #    reads both — strict match unchanged, `anywhere` takes the union.
      - id: nokey
        uses: ./sign
        with:
          binary: ${{ steps.bin.outputs.path }}
          key: ""
          gates: ci-lint-only
      - name: an empty key signs nothing and succeeds
        run: |
          set -eu
          [ '${{ steps.nokey.outputs.status }}' = no-key ] || exit 1
          [ '${{ steps.nokey.outputs.signed }}' = false ] || exit 1
      - id: readkey
        run: |
          set -eu
          {
            echo 'key<<ATTEST_EOF'
            cat "$RUNNER_TEMP/k"
            echo 'ATTEST_EOF'
          } >> "$GITHUB_OUTPUT"
      - id: appended
        uses: ./sign
        with:
          binary: ${{ steps.bin.outputs.path }}
          key: ${{ steps.readkey.outputs.key }}
          gates: ci-lint-only
          platform: aarch64-macos
          push: false
      - name: the block was appended locally, beside origin's mirror
        run: |
          set -eu
          [ '${{ steps.appended.outputs.status }}' = local ] || { echo 'status: ${{ steps.appended.outputs.status }}'; exit 1; }
          tree=$(git rev-parse 'HEAD^{tree}')
          n=$(git notes --ref attest-local/amont-attest show "$tree" | grep -c '^-----BEGIN SSH SIGNATURE-----$')
          [ "$n" = 1 ] || { echo "expected 1 unpushed block, got $n"; exit 1; }
          n=$(git notes --ref amont-attest show "$tree" | grep -c '^-----BEGIN SSH SIGNATURE-----$')
          [ "$n" = 1 ] || { echo "expected 1 block in the mirror, got $n"; exit 1; }
      - id: strict
        uses: ./
        with:
          binary: ${{ steps.bin.outputs.path }}
          signers: ${{ runner.temp }}/ci-signers
          include-local: true
      - id: loose
        uses: ./
        with:
          binary: ${{ steps.bin.outputs.path }}
          signers: ${{ runner.temp }}/ci-signers
          anywhere: ci-lint-only
          include-local: true
      - id: osonly
        uses: ./
        with:
          binary: ${{ steps.bin.outputs.path }}
          signers: ${{ runner.temp }}/ci-signers
          platform: macos
          include-local: true
      - id: nolocal
        uses: ./
        with:
          binary: ${{ steps.bin.outputs.path }}
          signers: ${{ runner.temp }}/ci-signers
          anywhere: ci-lint-only
      - name: strict, anywhere and os-only read the two blocks as specified
        run: |
          set -eu
          [ '${{ steps.strict.outputs.gates }}' = '["pre-push-cargo-test","pre-push-clippy"]' ] || {
            echo 'strict: ${{ steps.strict.outputs.gates }}'; exit 1; }
          [ '${{ steps.loose.outputs.gates }}' = '["pre-push-cargo-test","pre-push-clippy","ci-lint-only"]' ] || {
            echo 'anywhere: ${{ steps.loose.outputs.gates }}'; exit 1; }
          [ '${{ steps.osonly.outputs.gates }}' = '["ci-lint-only"]' ] || {
            echo 'os-only: ${{ steps.osonly.outputs.gates }}'; exit 1; }
          # Without include-local the unpushed block is not read at all.
          [ '${{ steps.nolocal.outputs.gates }}' = '["pre-push-cargo-test","pre-push-clippy"]' ] || {
            echo 'no include-local: ${{ steps.nolocal.outputs.gates }}'; exit 1; }
          [ '${{ steps.nolocal.outputs.notes }}' = fetched ] || {
            echo 'notes: ${{ steps.nolocal.outputs.notes }}'; exit 1; }

      # 5. Input fingerprints, with the real producer. Sign ci-fmt on this
      #    tree with the local key, move the tree with a commit that touches
      #    none of ci-fmt's declared inputs, and the verifier must still cover
      #    it — by fingerprint, from the inputs ref. Then touch a declared
      #    input and it must not.
      - id: readkey2
        run: |
          set -eu
          git notes --ref attest-local/amont-attest remove "$(git rev-parse 'HEAD^{tree}')" 2>/dev/null || true
          {
            echo 'key<<ATTEST_EOF'
            cat "$RUNNER_TEMP/k"
            echo 'ATTEST_EOF'
          } >> "$GITHUB_OUTPUT"
      - id: fpsign
        uses: ./sign
        with:
          binary: ${{ steps.bin.outputs.path }}
          key: ${{ steps.readkey2.outputs.key }}
          gates: ci-fmt
          push: false
      - name: the producer wrote a fingerprint and filed the block under it
        run: |
          set -eu
          [ '${{ steps.fpsign.outputs.inputs }}' = 1/1 ] || { echo 'inputs: ${{ steps.fpsign.outputs.inputs }}'; exit 1; }
          git notes --ref attest-local/amont-attest show "$(git rev-parse 'HEAD^{tree}')" | grep -q '^input ci-fmt [0-9a-f]\{40,64\}$'
          echo unrelated > UNRELATED.md && git add UNRELATED.md && git commit -qm unrelated
      - id: fpmoved
        uses: ./
        with:
          binary: ${{ steps.bin.outputs.path }}
          signers: ${{ runner.temp }}/ci-signers
          include-local: true
      - name: an unrelated commit keeps the gate covered, by fingerprint
        run: |
          set -eu
          [ '${{ steps.fpmoved.outputs.gates }}' = '["ci-fmt"]' ] || { echo 'got: ${{ steps.fpmoved.outputs.gates }}'; exit 1; }
          echo '// touched' >> src/main.rs && git add src/main.rs && git commit -qm touch
      - id: fpinput
        uses: ./
        with:
          binary: ${{ steps.bin.outputs.path }}
          signers: ${{ runner.temp }}/ci-signers
          include-local: true
      - name: a commit to a declared input does not
        run: |
          set -eu
          [ '${{ steps.fpinput.outputs.gates }}' = '[]' ] || { echo 'got: ${{ steps.fpinput.outputs.gates }}'; exit 1; }
          # Back to the original tree, and without the fingerprint-keyed
          # block: the steps below reason about the tree key alone.
          git reset -q --hard HEAD~2
          git update-ref -d refs/notes/attest-local/amont-attest-inputs

      # 6. The substring bug this project exists to fix. `pre-push-cargo-test`
      #    is a substring of the covered `…-slow`, so the old string form would
      #    skip the real suite here. Array membership must not.
      - name: re-sign with only a prefix-colliding gate
        run: |
          set -eu
          tree=$(git rev-parse 'HEAD^{tree}')
          printf 'amont-attest-v2\ntree %s\ngates pre-push-cargo-test-slow\nplatform x86_64-linux\namont ci\n' \
            "$tree" > "$RUNNER_TEMP/p2"
          ssh-keygen -Y sign -n amont-attest -f "$RUNNER_TEMP/k" "$RUNNER_TEMP/p2"
          git notes --ref amont-attest add -f \
            -m "$(printf '%s\n\n%s' "$(cat "$RUNNER_TEMP/p2")" "$(cat "$RUNNER_TEMP/p2.sig")")" "$tree"
          git push -q -f origin refs/notes/amont-attest:refs/notes/amont-attest
      - id: collide
        uses: ./
        with:
          binary: ${{ steps.bin.outputs.path }}
          signers: ${{ runner.temp }}/ci-signers
      - name: a merely-prefix-matching gate does NOT count as covered
        run: |
          set -eu
          [ '${{ steps.collide.outputs.gates }}' = '["pre-push-cargo-test-slow"]' ] || exit 1
          # the legacy string form WOULD have matched — that is the defect:
          case '${{ steps.collide.outputs.covered }}' in
            *pre-push-cargo-test*) echo "substring match, as expected of the old form" ;;
          esac
      - name: and the array form correctly says it is not covered
        if: ${{ !contains(fromJSON(steps.collide.outputs.gates), 'pre-push-cargo-test') }}
        run: echo "the real suite would still run — correct"

      # 7. The signing key never reaches a child process's environment. A spy
      #    ssh-keygen first on PATH records whether ATTEST_KEY is set when the
      #    sign action runs it, then execs the real one — by the absolute path
      #    taken BEFORE PATH changes, or it would call itself. LAST in the job:
      #    GITHUB_PATH cannot be undone for the steps after it.
      - name: install a spy ssh-keygen
        run: |
          set -eu
          real=$(command -v ssh-keygen)
          mkdir -p "$RUNNER_TEMP/spy"
          printf '#!/usr/bin/env bash\nprintf "%%s\\n" "${ATTEST_KEY+leaked}" >> "%s/spy.log"\nexec "%s" "$@"\n' \
            "$RUNNER_TEMP" "$real" > "$RUNNER_TEMP/spy/ssh-keygen"
          chmod +x "$RUNNER_TEMP/spy/ssh-keygen"
          : > "$RUNNER_TEMP/spy.log"
          echo "$RUNNER_TEMP/spy" >> "$GITHUB_PATH"
      - id: readkey3
        run: |
          set -eu
          {
            echo 'key<<ATTEST_EOF'
            cat "$RUNNER_TEMP/k"
            echo 'ATTEST_EOF'
          } >> "$GITHUB_OUTPUT"
      - id: spied
        uses: ./sign
        with:
          binary: ${{ steps.bin.outputs.path }}
          key: ${{ steps.readkey3.outputs.key }}
          gates: ci-spy
          push: false
      - name: the key was not in ssh-keygen's environment
        run: |
          set -eu
          [ '${{ steps.spied.outputs.status }}' = local ] || { echo 'status: ${{ steps.spied.outputs.status }}'; exit 1; }
          [ -s "$RUNNER_TEMP/spy.log" ] || { echo "the spy was never run"; exit 1; }
          if grep -qx leaked "$RUNNER_TEMP/spy.log"; then echo "ATTEST_KEY reached ssh-keygen"; exit 1; fi
          echo "ssh-keygen ran $(wc -l < "$RUNNER_TEMP/spy.log") time(s) without the key in its environment"

  msrv:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - run: |
          v=$(awk -F'"' '/^rust-version/ {print $2}' Cargo.toml)
          rustup toolchain install "$v" --profile minimal
          cargo "+$v" check --locked