git-attest 1.3.0

Verify a signed attestation that a test suite already passed on this exact tree, so CI can skip it
# Tag-driven release.
#
# The trigger is `v*.*.*` — three parts, deliberately. The moving major alias
# (`v1`) that `uses:` pins to must NOT start a release run, or every alias move
# would re-run a publish against an immutable registry.
name: release

on:
  push:
    tags: ["v*.*.*"]

# Read by default; the two jobs that write a release or move a tag say so.
permissions:
  contents: read

defaults:
  run:
    shell: bash

jobs:
  # Nothing is published before the tag and the manifest agree. A mismatch here
  # is the failure that cannot be undone afterwards: crates.io versions are
  # immutable, so a wrong number can only be superseded.
  guard:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.v.outputs.version }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - id: v
        run: |
          set -eu
          tag=${GITHUB_REF#refs/tags/v}
          manifest=$(awk -F'"' '/^version/ {print $2; exit}' Cargo.toml)
          if [ "$tag" != "$manifest" ]; then
            echo "tag v$tag but Cargo.toml says $manifest" >&2
            exit 1
          fi
          echo "version=$tag" >> "$GITHUB_OUTPUT"
      - run: make check

  build:
    needs: guard
    strategy:
      fail-fast: false
      matrix:
        include:
          - { target: aarch64-apple-darwin,       os: macos-latest }
          - { target: x86_64-apple-darwin,        os: macos-latest }
          - { target: x86_64-unknown-linux-gnu,   os: ubuntu-latest }
          - { target: aarch64-unknown-linux-gnu,  os: ubuntu-latest }
          - { target: x86_64-unknown-linux-musl,  os: ubuntu-latest }
          - { target: x86_64-pc-windows-msvc,     os: windows-latest }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - run: rustup target add ${{ matrix.target }}
      - name: cross toolchain
        if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' || matrix.target == 'x86_64-unknown-linux-musl' }}
        run: |
          set -eu
          sudo apt-get update
          if [ "${{ matrix.target }}" = aarch64-unknown-linux-gnu ]; then
            sudo apt-get install -y gcc-aarch64-linux-gnu
            echo 'CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc' >> "$GITHUB_ENV"
          else
            sudo apt-get install -y musl-tools
          fi
      - run: cargo build --release --locked --target ${{ matrix.target }}
      - name: archive
        run: |
          set -eu
          name=git-attest-${{ needs.guard.outputs.version }}-${{ matrix.target }}
          mkdir -p "dist/$name"
          if [ "${{ matrix.os }}" = windows-latest ]; then
            cp "target/${{ matrix.target }}/release/git-attest.exe" "dist/$name/"
          else
            cp "target/${{ matrix.target }}/release/git-attest" "dist/$name/"
          fi
          cp README.md LICENSE SPEC.md "dist/$name/"
          tar czf "dist/$name.tar.gz" -C dist "$name"
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: ${{ matrix.target }}
          path: dist/*.tar.gz

  publish:
    needs: [guard, build]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
        with: { path: art, merge-multiple: true }
      - run: cd art && sha256sum ./*.tar.gz > SHA256SUMS
      - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
        with:
          files: |
            art/*.tar.gz
            art/SHA256SUMS
          generate_release_notes: true
          # The commit, so a consumer can pin the step that decides whether
          # tests run to something that cannot move.
          body: |
            Pin by commit: `uses: fredericrous/attest@${{ github.sha }} # v${{ needs.guard.outputs.version }}`

  # crates.io is OPTIONAL here, and the job says so rather than failing.
  #
  # The primary artifact of this repository is the action, which is consumed by
  # `uses:` and needs no registry at all. Wiring the release so a missing
  # CARGO_REGISTRY_TOKEN takes the whole release down would hold the action
  # hostage to a distribution channel nobody has asked for yet.
  #
  # To enable it: `gh secret set CARGO_REGISTRY_TOKEN --repo fredericrous/attest`.
  publish-crates:
    needs: [guard, build]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
      - id: token
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          set -eu
          if [ -n "${CARGO_REGISTRY_TOKEN:-}" ]; then
            echo "have=yes" >> "$GITHUB_OUTPUT"
          else
            echo "have=no" >> "$GITHUB_OUTPUT"
            echo "::notice::no CARGO_REGISTRY_TOKEN — skipping crates.io"
          fi
      - if: steps.token.outputs.have == 'yes'
        run: cargo publish --locked
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

  # `uses: fredericrous/attest@v1` is how an action is pinned, so the major
  # alias has to follow the releases. This is the one tag that legitimately
  # moves — it is a pointer, not a release identity, and the `v*.*.*` trigger
  # above means moving it publishes nothing and starts no run.
  move-major-alias:
    # `guard` is in `needs` for its OUTPUT, not its ordering: the `needs`
    # context exposes only the jobs a job declares, so reading
    # needs.guard.outputs.version without listing `guard` yields an empty
    # string — which silently produced a tag literally named `v`.
    needs: [guard, publish, publish-crates]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: move the major alias
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -eu
          version="${{ needs.guard.outputs.version }}"
          # Refuse rather than invent. An empty version here previously became
          # the tag `v`; a release that cannot name its own major is a bug, not
          # something to paper over.
          case "$version" in
            [0-9]*.[0-9]*.[0-9]*) ;;
            *) echo "guard produced no usable version: '$version'" >&2; exit 1 ;;
          esac
          major=v${version%%.*}

          # The git refs API, NOT `git push`.
          #
          # GITHUB_TOKEN is refused when it pushes a ref whose commit carries a
          # .github/workflows/ file differing from the default branch:
          #
          #   refusing to allow a GitHub App to create or update workflow
          #   `.github/workflows/release.yaml` without `workflows` permission
          #
          # That is invisible on an ordinary release, where the tagged commit
          # IS the default branch, and it fires on a tag cut from a hotfix
          # branch or on a re-run after main has moved — i.e. exactly when
          # somebody is already dealing with something else. Updating a ref
          # moves a pointer and never presents file contents, so the guard does
          # not apply. No checkout is needed either.
          if gh api "repos/$GITHUB_REPOSITORY/git/refs/tags/$major" > /dev/null 2>&1; then
            gh api -X PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$major" \
              -f sha="$GITHUB_SHA" -F force=true > /dev/null
          else
            gh api -X POST "repos/$GITHUB_REPOSITORY/git/refs" \
              -f ref="refs/tags/$major" -f sha="$GITHUB_SHA" > /dev/null
          fi

          # Verify the EFFECT, and that it is the RIGHT ref — the previous
          # version checked only that *a* ref it had pushed existed, which was
          # true of the wrongly-named `v` it had just created.
          got=$(gh api "repos/$GITHUB_REPOSITORY/git/refs/tags/$major" --jq .object.sha)
          [ "$got" = "$GITHUB_SHA" ] || {
            echo "$major points at $got, expected $GITHUB_SHA" >&2; exit 1; }
          echo "$major -> $got"