git-attest 1.4.0

Verify a signed attestation that a test suite already passed on this exact tree, so CI can skip it
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 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]
    runs-on: ${{ matrix.os }}
    # Windows spawns processes slowly and the suites run three verifiers over
    # 120 fixtures plus the producer's 71; it needs about 25 minutes there.
    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 || '[]'), 'ci-conformance') }}
        run: make conformance sign-test
        shell: bash
      - uses: ./sign
        with:
          key: ${{ secrets.ATTEST_SIGNING_KEY }}
          gates: >-
            ${{ steps.test.outcome        == 'success' && 'pre-push-cargo-test' || '' }}
            ${{ steps.conformance.outcome == 'success' && 'ci-conformance'      || '' }}

  # 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.
  action:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - 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: ./
      - 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:
          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:
          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:
          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:
          signers: ${{ runner.temp }}/ci-signers
          include-local: true
      - id: loose
        uses: ./
        with:
          signers: ${{ runner.temp }}/ci-signers
          anywhere: ci-lint-only
          include-local: true
      - id: osonly
        uses: ./
        with:
          signers: ${{ runner.temp }}/ci-signers
          platform: macos
          include-local: true
      - id: nolocal
        uses: ./
        with:
          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:
          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:
          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:
          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:
          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:
          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