From 1f17a239c91ec244072556c530909254914f8b3c Mon Sep 17 00:00:00 2001 From: Perry Hertler Date: Wed, 26 Aug 2026 18:38:34 -0500 Subject: [PATCH] chore(ci): harden the release supply chain Binaries are distributed via DotSlash, which verifies only the `size` and BLAKE3 `digest` recorded in the DotSlash file -- there is no signature check anywhere in the tool. The digest is the whole mechanism, and it is only as trustworthy as the file holding it. This reworks how that file is produced and consumed, and closes the gaps around it. README: - Commit the DotSlash file and upgrade via a reviewed diff. Re-fetching it next to the binary means trusting whatever the release currently claims, so the digest verifies nothing. This is DotSlash's own guidance. - Document `gh attestation verify`, and note that ad-hoc signing does not satisfy Gatekeeper for browser downloads. Pipeline: - Create the release as a draft, publish only once every asset has landed. Required for immutable releases, which reject uploads to a published release. - Attest build provenance after packaging, so the digest the attestation covers matches what users download. - Pin third-party actions to commit SHAs; add dependabot to keep the pins fresh. Pin `cross` to 0.2.5. - Match DotSlash assets by exact name. An unanchored prefix regex could select the generated DotSlash file itself as a binary on a re-run. - Set `exclude-http-provider`. Generating against a draft bakes an `untagged-` asset URL into the published file; it 404s once the real tag exists and cannot be corrected under immutable releases. - Re-sign after `lipo` so the universal binary carries one coherent ad-hoc signature, and verify rather than assume. - Reuse an existing draft instead of creating a duplicate, `--clobber` on uploads, and a concurrency group that queues on main. Known gap: macOS Developer ID signing and notarization. Ad-hoc signing asserts nothing about who built the binary; provenance currently comes from the attestations. Tracked separately. Co-Authored-By: Claude Fable 5 --- .github/dependabot.yml | 14 +++ .github/workflows/audit.yml | 4 +- .github/workflows/ci.yml | 143 ++++++++++++++++++++----- .github/workflows/dotslash-config.json | 47 ++++---- README.md | 64 ++++++++++- 5 files changed, 218 insertions(+), 54 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..accc445 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + # Actions are pinned to full-length commit SHAs, which is the only way to + # consume an action as an immutable release. Dependabot bumps both the SHA + # and the trailing `# vX.Y.Z` comment, so the pins do not go stale. + # https://docs.github.com/en/actions/reference/security/secure-use + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - '*' diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index f937aaa..0160d00 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -25,7 +25,7 @@ jobs: env: RUSTUP_TOOLCHAIN: stable steps: - - uses: actions/checkout@v4 - - uses: rustsec/audit-check@v2.0.0 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 355b537..2d833e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,13 +20,22 @@ env: permissions: contents: read +# Two pushes to main in quick succession would otherwise be able to run two +# `release` jobs concurrently, and a draft release is not protected by tag +# uniqueness the way a published one is (see the release job below). +# On main, queue rather than cancel -- cancelling mid-release would leave a +# partial draft. Everywhere else, superseding an in-flight run is what you want. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + jobs: check: name: Check runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Run cargo check run: cargo check @@ -35,7 +44,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Run cargo test with backtrace run: cargo test -- --nocapture @@ -48,7 +57,7 @@ jobs: RUSTFLAGS: "-Dwarnings" steps: - name: Checkout sources - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Run cargo fmt run: cargo fmt --all -- --check @@ -56,8 +65,14 @@ jobs: - name: Run cargo clippy run: cargo clippy --all-targets --all-features + # The release is created as a DRAFT and only published once every asset -- + # binaries plus the generated DotSlash files -- has been uploaded. This + # ordering is required for immutable releases: once a release is published, + # GitHub rejects further asset uploads with + # "Cannot upload assets to an immutable release". + # See https://github.com/actions/attest-build-provenance/issues/734 release: - runs-on: macos-latest + runs-on: ubuntu-latest permissions: contents: write needs: @@ -69,19 +84,12 @@ jobs: changed: ${{ steps.check_for_version_changes.outputs.changed }} if: github.ref == 'refs/heads/main' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: # https://stackoverflow.com/questions/65944700/how-to-run-git-diff-in-github-actions # TLDR – By default this action fetches no history. # We need a bit of history to be able to check if we've recently updated the version in Cargo.toml fetch-depth: 2 - - name: Toolchain info - run: | - cargo --version --verbose - rustc --version - cargo clippy --version - - name: Build - run: cargo build --release --target aarch64-apple-darwin --target x86_64-apple-darwin - name: Check for version changes in Cargo.toml id: check_for_version_changes run: | @@ -97,59 +105,121 @@ jobs: echo "changed=false" >> $GITHUB_OUTPUT fi - - name: Create GitHub Release if current commit has updated the version in Cargo.toml + - name: Create draft GitHub Release if current commit has updated the version in Cargo.toml if: steps.check_for_version_changes.outputs.changed == 'true' run: | - gh release create ${{steps.check_for_version_changes.outputs.new_version}} --target "${{ github.sha }}" --generate-notes + # A draft release does not create a git tag, so GitHub does NOT enforce + # tag uniqueness for drafts -- `gh release create` would happily make a + # second draft with the same pending tag name, and the DotSlash + # generator resolves whichever one the API returns first. That is + # reachable via "Re-run all jobs" after a failed upload, so reuse an + # existing draft instead of creating a duplicate. + if [[ "$(gh release view "$NEW_VERSION" --json isDraft --jq .isDraft 2>/dev/null)" == "true" ]]; then + echo "Draft $NEW_VERSION already exists; reusing it." + else + gh release create "$NEW_VERSION" --target "$COMMIT_SHA" --generate-notes --draft + fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_VERSION: ${{ steps.check_for_version_changes.outputs.new_version }} + COMMIT_SHA: ${{ github.sha }} + upload-mac-universal-bin: needs: release runs-on: macos-latest permissions: contents: write - if: ${{needs.release.outputs.new_version}} + # Required by actions/attest-build-provenance: id-token to mint the OIDC + # token for the Sigstore signing certificate, attestations to persist the + # resulting attestation. + id-token: write + attestations: write + if: needs.release.outputs.new_version != '' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Build run: cargo build --release --target aarch64-apple-darwin --target x86_64-apple-darwin - - name: Upload mac universal binary + - name: Create universal binary run: | # This combines the intel and m1 binaries into a single binary lipo -create -output target/codeowners target/aarch64-apple-darwin/release/codeowners target/x86_64-apple-darwin/release/codeowners + # lipo carries over the per-architecture linker (ad-hoc) signatures, + # but Apple recommends re-signing the merged binary so it carries a + # single coherent signature. All code on Apple silicon must be at + # least ad-hoc signed or it is SIGKILLed on launch, so verify rather + # than assume. https://developer.apple.com/forums/thread/708552 + # + # `--sign -` is the ad-hoc identity, not a placeholder for a cert + # name: no certificate, no keychain, no Developer ID, nothing to + # provision in CI. It only makes the binary internally consistent and + # asserts nothing about who built it -- provenance comes from the + # attestation step below, not from codesign. + codesign --force --sign - target/codeowners + codesign --verify --verbose target/codeowners + # Creates artifact for homebrew. -C means run from `target` directory + # NOTE: any mutation of the binary must happen BEFORE signing, and + # any repackaging must happen BEFORE attestation, or the digest the + # attestation covers will not match what users download. tar -czf target/codeowners-mac.tar.gz -C target codeowners - # This tarball is a binary that is executable - gh release upload $NEW_VERSION target/codeowners-mac.tar.gz + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: target/codeowners-mac.tar.gz + - name: Upload mac universal binary + run: | + # This tarball is a binary that is executable. + # --clobber so re-running after a partial failure replaces the asset + # instead of failing on "asset already exists". + gh release upload "$NEW_VERSION" target/codeowners-mac.tar.gz --clobber env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NEW_VERSION: ${{ needs.release.outputs.new_version }} upload-linux-bin: needs: release - if: ${{needs.release.outputs.new_version}} + if: needs.release.outputs.new_version != '' runs-on: ubuntu-latest permissions: contents: write + id-token: write + attestations: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Update local toolchain run: | - cargo install cross + # Pinned so a future cross release cannot change how release binaries + # are built without a reviewed commit. 0.2.5 is what an unpinned + # `cargo install cross` resolves to today, so this is not a version + # bump. Deliberately not --locked: cross 0.2.5's lockfile predates + # current rustc and this build cannot be exercised outside `main`. + cargo install cross --version 0.2.5 - name: Build linux binaries run: | cross build --release --target x86_64-unknown-linux-gnu cross build --release --target aarch64-unknown-linux-gnu - - name: Upload linux binaries + - name: Package linux binaries run: | tar -czf target/x86_64-unknown-linux-gnu.tar.gz -C target/x86_64-unknown-linux-gnu/release codeowners tar -czf target/aarch64-unknown-linux-gnu.tar.gz -C target/aarch64-unknown-linux-gnu/release codeowners - gh release upload $NEW_VERSION target/x86_64-unknown-linux-gnu.tar.gz - gh release upload $NEW_VERSION target/aarch64-unknown-linux-gnu.tar.gz + + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + target/x86_64-unknown-linux-gnu.tar.gz + target/aarch64-unknown-linux-gnu.tar.gz + + - name: Upload linux binaries + run: | + # --clobber so re-running after a partial failure replaces the assets + # instead of failing on "asset already exists". + gh release upload "$NEW_VERSION" target/x86_64-unknown-linux-gnu.tar.gz --clobber + gh release upload "$NEW_VERSION" target/aarch64-unknown-linux-gnu.tar.gz --clobber env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NEW_VERSION: ${{ needs.release.outputs.new_version }} @@ -160,13 +230,13 @@ jobs: - release - upload-linux-bin - upload-mac-universal-bin - if: success() && ${{needs.release.outputs.new_version}} + if: needs.release.outputs.new_version != '' runs-on: ubuntu-latest permissions: contents: write steps: - - uses: facebook/dotslash-publish-release@v1 + - uses: facebook/dotslash-publish-release@2539c4d8ae00a42773306c8731d2dd3724d979d2 # v1 # This is necessary because the action uses # `gh release upload` to publish the generated DotSlash file(s) # as part of the release. @@ -178,3 +248,22 @@ jobs: config: .github/workflows/dotslash-config.json # Tag for the release to target. tag: ${{ needs.release.outputs.new_version }} + + # Publishing last is what makes immutable releases workable: every asset is + # in place before the release becomes visible and frozen. + publish-release: + name: Publish the release + needs: + - release + - generate-dotslash-files + if: needs.release.outputs.new_version != '' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Undraft the release + run: | + gh release edit "$NEW_VERSION" --draft=false --repo "$GITHUB_REPOSITORY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_VERSION: ${{ needs.release.outputs.new_version }} diff --git a/.github/workflows/dotslash-config.json b/.github/workflows/dotslash-config.json index 3fd237f..da7b67c 100644 --- a/.github/workflows/dotslash-config.json +++ b/.github/workflows/dotslash-config.json @@ -1,28 +1,29 @@ { - "outputs": { - "codeowners": { - "platforms": { - "macos-x86_64": { - "regex": "^codeowners-mac", - "path": "codeowners", - "format": "tar.gz" - }, - "macos-aarch64": { - "regex": "^codeowners-mac", - "path": "codeowners", - "format": "tar.gz" - }, - "linux-x86_64": { - "regex": "^x86_64-unknown-linux", - "path": "codeowners", - "format": "tar.gz" - }, - "linux-aarch64": { - "regex": "^aarch64-unknown-linux", - "path": "codeowners", - "format": "tar.gz" - } + "exclude-http-provider": true, + "outputs": { + "codeowners": { + "platforms": { + "macos-x86_64": { + "name": "codeowners-mac.tar.gz", + "path": "codeowners", + "format": "tar.gz" + }, + "macos-aarch64": { + "name": "codeowners-mac.tar.gz", + "path": "codeowners", + "format": "tar.gz" + }, + "linux-x86_64": { + "name": "x86_64-unknown-linux-gnu.tar.gz", + "path": "codeowners", + "format": "tar.gz" + }, + "linux-aarch64": { + "name": "aarch64-unknown-linux-gnu.tar.gz", + "path": "codeowners", + "format": "tar.gz" } } } } +} diff --git a/README.md b/README.md index ebbafcf..e278777 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,68 @@ You can run `codeowners` without installing a platform-specific binary by using ### Option A: DotSlash (recommended) 1. Install DotSlash: see [https://dotslash-cli.com/docs/installation/](https://dotslash-cli.com/docs/installation/) -2. Download the latest DotSlash text file from a release, for example [https://github.com/rubyatscale/codeowners-rs/releases](https://github.com/rubyatscale/codeowners-rs/releases). -3. Execute the downloaded file with DotSlash; it will fetch and run the correct binary. +2. Download the `codeowners` DotSlash text file from the release you want to pin + to: [https://github.com/rubyatscale/codeowners-rs/releases](https://github.com/rubyatscale/codeowners-rs/releases) +3. **Commit that file into your repository** (for example at `bin/codeowners`) and + mark it executable with `chmod +x`. +4. Run it like any other executable — `./bin/codeowners --help`. DotSlash fetches, + verifies, and caches the correct binary for the current platform on first use. + +Upgrading is a deliberate act: download the DotSlash file from the newer release, +replace the committed one, and review the diff. The digests change, so the change +is visible in code review. + +#### Why commit the file rather than re-download it + +A DotSlash file records the expected `size` and BLAKE3 `digest` of each platform's +binary, and DotSlash refuses to unpack or execute an artifact that does not match. +That check is only worth something if the digest itself is trusted — and the digest +is trusted because the file lives in your repository and changes to it go through +review. If you re-download the DotSlash file alongside the binary every time, you +are trusting whatever the release currently claims, and the digest verifies nothing +you did not just fetch from the same place. + +This is also DotSlash's own guidance: "the provenance of a DotSlash file is +paramount", and DotSlash files "are meant to live in source control" +([motivation](https://github.com/facebook/dotslash/blob/main/website/docs/motivation.md)). + +#### Verifying a release independently (optional) + +DotSlash verifies digests but does not check signatures. Release binaries are built +by GitHub Actions and carry [SLSA build provenance](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds) +attestations, which you can verify with the GitHub CLI: + +```sh +gh attestation verify codeowners-mac.tar.gz --repo rubyatscale/codeowners-rs +``` + +To additionally require that the attestation came from this repository's release +workflow rather than any workflow in it: + +```sh +gh attestation verify codeowners-mac.tar.gz \ + --repo rubyatscale/codeowners-rs \ + --signer-workflow rubyatscale/codeowners-rs/.github/workflows/ci.yml +``` + +Attestation was added to the release pipeline after v0.3.4, so releases up to and +including v0.3.4 have none — `gh attestation verify` reports "no matching +attestations found" for them. That is expected for those versions and is not +evidence of tampering. + +Note that macOS binaries are ad-hoc (linker) signed only — they are not signed with +an Apple Developer ID and are not notarized. Ad-hoc signing satisfies Apple +silicon's requirement that all code be signed, but it does not satisfy Gatekeeper. + +Whether that matters depends on how you fetch the binary. DotSlash and `curl` do not +set the `com.apple.quarantine` attribute, so Gatekeeper never evaluates the binary +and it runs normally. If you download the tarball from the releases page in a +browser, quarantine *is* set and Gatekeeper will refuse to run it until you clear +the attribute: + +```sh +xattr -d com.apple.quarantine codeowners +``` ### Option B: From source with Cargo