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