From 344da50ec429d0baf5104310cbd66a0a03490f54 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 3 Oct 2026 21:04:08 +1000 Subject: [PATCH 1/4] ci(release): drive releases with release-please, held below 1.0 (LAB-5117) Every push to main runs release-please (rust release type, bump-minor-pre-major, so a breaking change bumps the minor version while the crate is below 1.0). Merging its release PR creates the tag and GitHub Release; the same workflow run then tests, builds, attests and uploads the binaries and publishes the crate. Tags created by the App do not trigger workflows, so the release no longer runs on a tag push. The README's verify command also pins --source-ref refs/heads/main. --- .github/workflows/release.yml | 97 ++++++++++++++++++++++------------- .release-please-manifest.json | 3 ++ README.md | 5 +- release-please-config.json | 26 ++++++++++ 4 files changed, 93 insertions(+), 38 deletions(-) create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82cbabd..d47c3e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,16 @@ 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. A pushed tag releases nothing: tags the App creates do +# not trigger workflows, so the whole release runs in this one workflow. +# Users verify a binary against this file's path and refs/heads/main, so +# renaming the file or changing the trigger breaks the README's verify command. on: push: - tags: ["v*"] + branches: [main] permissions: contents: read @@ -17,36 +20,66 @@ 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 }} + 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 + 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 + 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 }} + run: gh pr edit "$(jq -er .number <<< "$PR_JSON")" --add-assignee 27Bslash6 + check: + needs: release-please + if: needs.release-please.outputs.release_created == 'true' runs-on: ubuntu-latest timeout-minutes: 15 + env: + TAG: ${{ needs.release-please.outputs.tag_name }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 + ref: ${{ needs.release-please.outputs.tag_name }} persist-credentials: false - 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 build: name: build (${{ matrix.target }}) - needs: check + needs: [release-please, check] runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: @@ -57,10 +90,12 @@ jobs: - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl } - { os: macos-latest, target: aarch64-apple-darwin } env: + TAG: ${{ needs.release-please.outputs.tag_name }} TARGET: ${{ matrix.target }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.release-please.outputs.tag_name }} persist-credentials: false - name: Install the musl toolchain @@ -79,7 +114,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 +126,13 @@ jobs: if-no-files-found: error release: - needs: build + needs: [release-please, 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 +152,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.release-please.outputs.tag_name }} + run: gh release upload "$TAG" --clobber dist/*.tar.gz publish: - needs: release + needs: [release-please, release] runs-on: ubuntu-latest timeout-minutes: 15 # crates.io accepts a token minted here only from this workflow in this @@ -150,12 +174,13 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ needs.release-please.outputs.tag_name }} 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 + } + } +} From 05feb5d8bf129b6a38b16ecf99fea62e22e5e45f Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 3 Oct 2026 21:05:29 +1000 Subject: [PATCH 2/4] ci(release): state why the release runs with release-please (LAB-5117) --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d47c3e0..abf778d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,9 @@ name: Release # 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. A pushed tag releases nothing: tags the App creates do -# not trigger workflows, so the whole release runs in this one workflow. +# 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. # Users verify a binary against this file's path and refs/heads/main, so # renaming the file or changing the trigger breaks the README's verify command. From 3a837940582f2d0374a8d86019f345ff8cf1cb08 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 3 Oct 2026 21:08:48 +1000 Subject: [PATCH 3/4] ci(release): add a dispatch path to rebuild an existing release (LAB-5117) Re-running every job after a failed release finds the release already created, so release-please reports no new release and the build jobs skip. A workflow_dispatch on main with the release tag now rebuilds, re-attests and re-uploads that release and retries the crates.io publish. The tag must name a release version on main. --- .github/workflows/release.yml | 53 +++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abf778d..b749ec4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,12 +6,21 @@ name: Release # 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 changing the trigger breaks the README's verify command. +# renaming the file or running it from another ref fails the README's check. on: push: 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 @@ -55,17 +64,39 @@ jobs: check: needs: release-please - if: needs.release-please.outputs.release_created == 'true' + 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 }} env: - TAG: ${{ needs.release-please.outputs.tag_name }} + # 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: - ref: ${{ needs.release-please.outputs.tag_name }} + ref: ${{ steps.tag.outputs.tag }} + fetch-depth: 0 persist-credentials: false + - name: Tagged commit is on main + # A dispatch can name any tag; only one on main may be built and + # attested as a run from main. + run: | + git merge-base --is-ancestor HEAD origin/main \ + || { echo "::error::$TAG is not on main"; exit 1; } + - name: Tag matches the crate version run: | version=$(cargo metadata --locked --no-deps --format-version 1 | jq -r '.packages[0].version') @@ -80,7 +111,7 @@ jobs: build: name: build (${{ matrix.target }}) - needs: [release-please, check] + needs: check runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: @@ -91,12 +122,12 @@ jobs: - { os: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl } - { os: macos-latest, target: aarch64-apple-darwin } env: - TAG: ${{ needs.release-please.outputs.tag_name }} + TAG: ${{ needs.check.outputs.tag }} TARGET: ${{ matrix.target }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.release-please.outputs.tag_name }} + ref: ${{ needs.check.outputs.tag }} persist-credentials: false - name: Install the musl toolchain @@ -127,7 +158,7 @@ jobs: if-no-files-found: error release: - needs: [release-please, build] + needs: [check, build] runs-on: ubuntu-latest timeout-minutes: 15 # Signing happens here rather than in the build jobs, so dependency build @@ -159,11 +190,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} - TAG: ${{ needs.release-please.outputs.tag_name }} + TAG: ${{ needs.check.outputs.tag }} run: gh release upload "$TAG" --clobber dist/*.tar.gz publish: - needs: [release-please, release] + needs: [check, release] runs-on: ubuntu-latest timeout-minutes: 15 # crates.io accepts a token minted here only from this workflow in this @@ -175,7 +206,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.release-please.outputs.tag_name }} + ref: ${{ needs.check.outputs.tag }} persist-credentials: false # Needs the crate to exist on crates.io with this workflow and the From 336665440c88b089cc3f1ed77f95240030e1afb1 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 3 Oct 2026 22:08:04 +1000 Subject: [PATCH 4/4] ci(release): build the verified commit; skip release-please on dispatch (LAB-5117) check now checks out refs/tags/, so a branch with the tag's name is never built, and outputs the commit it verified; build and publish check out that commit, so moving the tag after the check changes nothing. On a dispatch the release-please steps skip, so a release created mid-dispatch is not left without binaries. The release PR is assigned through the REST API, which an App token can call. --- .github/workflows/release.yml | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b749ec4..4fc6570 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,9 +36,13 @@ jobs: 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 }} @@ -49,6 +53,7 @@ jobs: permission-issues: write - uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38 # v4 + if: github.event_name == 'push' id: release with: token: ${{ steps.app-token.outputs.token }} @@ -60,7 +65,11 @@ jobs: GH_REPO: ${{ github.repository }} # Via env: the PR JSON carries branch and title text. PR_JSON: ${{ steps.release.outputs.pr }} - run: gh pr edit "$(jq -er .number <<< "$PR_JSON")" --add-assignee 27Bslash6 + # 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 @@ -71,6 +80,7 @@ jobs: 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 }} @@ -86,16 +96,20 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ steps.tag.outputs.tag }} + # 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. + # 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: | @@ -127,7 +141,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.check.outputs.tag }} + ref: ${{ needs.check.outputs.sha }} persist-credentials: false - name: Install the musl toolchain @@ -206,7 +220,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.check.outputs.tag }} + ref: ${{ needs.check.outputs.sha }} persist-credentials: false # Needs the crate to exist on crates.io with this workflow and the