diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82cbabd..4fc6570 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,26 @@ name: Release -# A version tag (v0.1.0, v0.2.0-rc.1) on a commit in main publishes that -# version: attested binaries on the GitHub Release, then the crate on -# crates.io. Users verify a binary against this file's path, so renaming it -# breaks the verify command in the README. +# Every push to main runs release-please, which keeps a release PR open with +# the next version and changelog. Merging that PR creates the tag and GitHub +# Release, and the jobs below then attach attested binaries and publish the +# crate to crates.io. The release runs in this workflow, not on the tag, so +# it never depends on a tag push starting a run, and a pushed tag releases +# nothing. +# If a release fails after release-please created it, rerun the failed jobs, +# or dispatch this workflow on main with the release tag: either rebuilds, +# re-attests and re-uploads that release and retries the crates.io publish. +# Users verify a binary against this file's path and refs/heads/main, so +# renaming the file or running it from another ref fails the README's check. on: push: - tags: ["v*"] + branches: [main] + workflow_dispatch: + inputs: + tag: + description: Existing release tag to rebuild and publish, e.g. v0.1.0 + required: true + type: string permissions: contents: read @@ -17,29 +30,95 @@ concurrency: cancel-in-progress: false jobs: + release-please: + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + # On a dispatch the steps skip but the job still succeeds: a release + # created here would otherwise lose its build to the dispatched tag, and a + # skipped job would skip every job after it. + steps: + # The App token, not GITHUB_TOKEN, opens the release PR, so CI runs on it. + - uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + if: github.event_name == 'push' + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + # Only what release-please and the assignee step use. + permission-contents: write + permission-pull-requests: write + permission-issues: write + + - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4 + if: github.event_name == 'push' + id: release + with: + token: ${{ steps.app-token.outputs.token }} + + - name: Assign the release PR to the maintainer + if: ${{ steps.release.outputs.pr }} + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_REPO: ${{ github.repository }} + # Via env: the PR JSON carries branch and title text. + PR_JSON: ${{ steps.release.outputs.pr }} + # REST, not gh pr edit, which also queries project fields an App + # token cannot read. + run: | + number=$(jq -er .number <<< "$PR_JSON") + gh api --method POST "repos/$GH_REPO/issues/$number/assignees" -f "assignees[]=27Bslash6" > /dev/null + check: + needs: release-please + if: >- + needs.release-please.outputs.release_created == 'true' + || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') runs-on: ubuntu-latest timeout-minutes: 15 + outputs: + tag: ${{ steps.tag.outputs.tag }} + sha: ${{ steps.sha.outputs.sha }} + env: + # A dispatch names the tag; a push releases what release-please created. + TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || needs.release-please.outputs.tag_name }} steps: + - name: Tag is a release version + id: tag + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::$TAG is not a release tag such as v0.1.0" + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # Fully qualified, so a branch with the tag's name is never built. + ref: refs/tags/${{ steps.tag.outputs.tag }} fetch-depth: 0 persist-credentials: false + - name: Tagged commit is on main + id: sha + # A dispatch can name any tag; only one on main may be built and + # attested as a run from main. Later jobs check out this exact commit, + # so a tag moved after this check cannot change what ships. + run: | + git merge-base --is-ancestor HEAD origin/main \ + || { echo "::error::$TAG is not on main"; exit 1; } + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Tag matches the crate version run: | version=$(cargo metadata --locked --no-deps --format-version 1 | jq -r '.packages[0].version') - if [ "$GITHUB_REF_NAME" != "v$version" ]; then - echo "::error::tag $GITHUB_REF_NAME does not match the crate version $version" + if [ "$TAG" != "v$version" ]; then + echo "::error::tag $TAG does not match the crate version $version" exit 1 fi - - name: Tagged commit is on main - # Catches a version tag pushed on the wrong commit by mistake. - run: | - git merge-base --is-ancestor "$GITHUB_SHA" origin/main \ - || { echo "::error::$GITHUB_SHA is not on main"; exit 1; } - - name: Package the crate # Builds the packaged crate, so the publish job can skip that build. run: cargo package --locked @@ -57,10 +136,12 @@ jobs: - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl } - { os: macos-latest, target: aarch64-apple-darwin } env: + TAG: ${{ needs.check.outputs.tag }} TARGET: ${{ matrix.target }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.check.outputs.sha }} persist-credentials: false - name: Install the musl toolchain @@ -79,7 +160,7 @@ jobs: - name: Archive run: | - dir="ck-$GITHUB_REF_NAME-$TARGET" + dir="ck-$TAG-$TARGET" mkdir "$dir" cp "target/$TARGET/release/ck" LICENSE README.md "$dir/" tar -czf "$dir.tar.gz" "$dir" @@ -91,13 +172,13 @@ jobs: if-no-files-found: error release: - needs: build + needs: [check, build] runs-on: ubuntu-latest timeout-minutes: 15 # Signing happens here rather than in the build jobs, so dependency build # scripts never run with a token that can sign or publish. permissions: - contents: write # create the release + contents: write # upload to the release id-token: write # sign the attestation attestations: write # store the attestation steps: @@ -117,28 +198,17 @@ jobs: dist/*.tar.gz dist/ck-*/ck - - name: Create the GitHub Release + - name: Upload to the GitHub Release + # release-please created the release. --clobber lets a rerun replace + # the archives with its own attested ones. env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - run: | - # A rerun, after a failed upload or publish, finds the release - # already there: replace its archives with this run's attested ones - # so the publish job still runs. gh creates a release with assets as - # a draft and publishes it after the uploads, so a run that died - # mid-upload can leave a draft behind; publish it here. - if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then - gh release upload "$GITHUB_REF_NAME" --clobber dist/*.tar.gz - gh release edit "$GITHUB_REF_NAME" --draft=false - exit - fi - prerelease=() - case "$GITHUB_REF_NAME" in *-*) prerelease=(--prerelease) ;; esac - gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes "${prerelease[@]}" \ - dist/*.tar.gz + TAG: ${{ needs.check.outputs.tag }} + run: gh release upload "$TAG" --clobber dist/*.tar.gz publish: - needs: release + needs: [check, release] runs-on: ubuntu-latest timeout-minutes: 15 # crates.io accepts a token minted here only from this workflow in this @@ -150,12 +220,13 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.check.outputs.sha }} persist-credentials: false # Needs the crate to exist on crates.io with this workflow and the # `release` environment registered as its trusted publisher. crates.io # attaches a publisher only to an existing crate, so 0.0.0 is published - # by hand with an API token first. Every tagged version comes after it, + # by hand with an API token first. Every released version comes after it, # because this job cannot publish a version that already exists. - uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5 id: auth diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/README.md b/README.md index 47d7892..0e1220e 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,12 @@ you unpack it: ```text gh attestation verify ck-v0.1.0-x86_64-unknown-linux-musl.tar.gz --repo cachekit-io/cachekit-cli \ --signer-workflow cachekit-io/cachekit-cli/.github/workflows/release.yml \ - --deny-self-hosted-runners + --source-ref refs/heads/main --deny-self-hosted-runners ``` The check passes only if the file was built by this repository's release -workflow on a GitHub-hosted runner. `--owner cachekit-io` alone is not enough: +workflow, run from `main`, on a GitHub-hosted runner. `--owner cachekit-io` +alone is not enough: it accepts a file built by any workflow in any `cachekit-io` repository. The `ck` binary inside the archive is attested too, so the same command verifies it after unpacking. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..52e748a --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "rust", + "bump-minor-pre-major": true, + "changelog-sections": [ + {"type": "feat", "section": "Features"}, + {"type": "fix", "section": "Bug Fixes"}, + {"type": "perf", "section": "Performance Improvements"}, + {"type": "security", "section": "Security"}, + {"type": "revert", "section": "Reverts"}, + {"type": "docs", "section": "Documentation", "hidden": true}, + {"type": "chore", "section": "Miscellaneous", "hidden": true}, + {"type": "refactor", "section": "Code Refactoring", "hidden": true}, + {"type": "style", "section": "Styles", "hidden": true}, + {"type": "test", "section": "Tests", "hidden": true}, + {"type": "ci", "section": "CI/CD", "hidden": true}, + {"type": "build", "section": "Build System", "hidden": true} + ], + "packages": { + ".": { + "package-name": "cachekit-cli", + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false + } + } +}