From a948d7747fffa6bd22a17c5555331e34687edbbb Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:34:30 -0700 Subject: [PATCH] ci: cut releases from a workflow that runs release.sh --- .github/workflows/cut-release.yml | 133 ++++++++++++++++++++++++++++++ RELEASING.md | 37 ++++++--- scripts/release.sh | 14 ++-- 3 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/cut-release.yml diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml new file mode 100644 index 0000000..e3eceec --- /dev/null +++ b/.github/workflows/cut-release.yml @@ -0,0 +1,133 @@ +# Cut a release entirely from CI, using the same scripts/release.sh a human +# would run locally. Three steps: +# +# 1. `gh workflow run cut-release.yml -f bump=` +# runs `release.sh prepare`: bumps Cargo.toml, rolls [Unreleased] into a +# versioned CHANGELOG section, pushes release/vX.Y.Z and opens the PR, +# then enables auto-merge on it. +# 2. The PR merges on its own once CI and the review gate pass. +# 3. The merge of a release/v* branch triggers the `publish` job below, which +# runs `release.sh publish`: tags main with vX.Y.Z and pushes the tag. +# The tag push then triggers publish.yml (crates.io) and release.yml +# (GitHub Release) as it always has. +# +# Every git/gh call uses a GitHub App token instead of GITHUB_TOKEN. PRs opened +# and tags pushed with GITHUB_TOKEN never trigger other workflows, so with it +# the release PR would get no CI checks and the tag would never publish. + +name: Cut Release + +on: + workflow_dispatch: + inputs: + bump: + description: 'patch | minor | major | explicit X.Y.Z' + required: true + default: patch + type: string + pull_request: + types: [closed] + branches: [main] + +permissions: {} + +concurrency: + group: cut-release + cancel-in-progress: false + +jobs: + prepare: + name: Open release PR + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: Iv23liKBX2RYMoZIYuKa + private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }} + owner: hotdata-dev + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 + fetch-tags: true + + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + + # release.sh bumps Cargo.toml with `cargo set-version` (cargo-edit). + - name: Install cargo-edit + uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28 + with: + tool: cargo-edit + + - name: Configure git identity + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + slug="${{ steps.app-token.outputs.app-slug }}[bot]" + id="$(gh api "/users/${slug}" --jq .id)" + git config user.name "$slug" + git config user.email "${id}+${slug}@users.noreply.github.com" + + - name: Prepare release + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: ./scripts/release.sh prepare "${{ inputs.bump }}" + + # Branch protection on main gates the merge on CI plus the review gate, + # so this only merges once everything is green. + - name: Enable auto-merge on the release PR + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + branch="$(git rev-parse --abbrev-ref HEAD)" + number="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number')" + [[ -n "$number" ]] || { echo "::error::no open PR for $branch"; exit 1; } + gh pr merge "$number" --squash --auto + + publish: + name: Tag release + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + startsWith(github.event.pull_request.head.ref, 'release/v') + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: Iv23liKBX2RYMoZIYuKa + private-key: ${{ secrets.HOTDATA_AUTOMATION_PRIVATE_KEY }} + owner: hotdata-dev + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + token: ${{ steps.app-token.outputs.token }} + fetch-depth: 0 + fetch-tags: true + + # release.sh reads the version with `cargo metadata`. + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: stable + + - name: Configure git identity + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + slug="${{ steps.app-token.outputs.app-slug }}[bot]" + id="$(gh api "/users/${slug}" --jq .id)" + git config user.name "$slug" + git config user.email "${id}+${slug}@users.noreply.github.com" + + - name: Tag and push + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: ./scripts/release.sh publish diff --git a/RELEASING.md b/RELEASING.md index d5ab9f5..761ce8e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,26 +5,26 @@ Every release uses `./scripts/release.sh`. Do not bump versions, tag, or create ## One-time setup - Install [GitHub CLI](https://cli.github.com/) (`gh`) and authenticate. -- Install [`cargo-edit`](https://github.com/killercup/cargo-edit) (`cargo install cargo-edit`) so `cargo set-version` is available. - Configure the `CRATES_IO_TOKEN` secret in the `crates-io` GitHub environment that `publish.yml` uses. +- `cut-release.yml` needs the `HOTDATA_AUTOMATION_PRIVATE_KEY` secret (the same GitHub App the + regeneration workflow uses). The App token is what lets the release PR get CI checks and the + tag trigger publishing: anything opened or pushed with `GITHUB_TOKEN` triggers no workflows. ## Release steps -1. Add user-facing notes under `## [Unreleased]` in `CHANGELOG.md`. -2. Prepare the release PR: +1. Make sure `## [Unreleased]` in `CHANGELOG.md` has user-facing notes. +2. Cut the release from CI: ```bash - ./scripts/release.sh prepare patch # or minor | major | 1.2.3 + gh workflow run cut-release.yml -f bump=minor # or patch | major | 1.2.3 ``` -3. Merge the PR after CI passes (including the changelog check). -4. Publish from a clean default branch checkout: - - ```bash - git checkout main - git pull - ./scripts/release.sh publish - ``` + This runs `./scripts/release.sh prepare` on a runner: it bumps `Cargo.toml`, rolls + `[Unreleased]` into a `## [X.Y.Z]` section, opens the `release/vX.Y.Z` PR, and enables + auto-merge on it. +3. The PR merges on its own once CI (including the changelog check) and the review gate pass. +4. Merging the PR runs `./scripts/release.sh publish` in CI, which tags `main` with `vX.Y.Z`. + The tag push publishes the crate and creates the GitHub Release. ## What happens automatically @@ -37,6 +37,17 @@ Pushing a `vX.Y.Z` tag triggers two workflows: The `arrow` feature ships as part of the same crate — features are crate metadata, not a separate publish. +## Recover a missing tag + +If the release PR merged but the `Tag release` job failed (so no `vX.Y.Z` tag exists), rerun +the failed job from the Actions tab, or tag from a clean local checkout: + +```bash +git checkout main +git pull +./scripts/release.sh publish +``` + ## Recover a missing GitHub Release If the crates.io publish succeeded but the GitHub Release workflow failed, rerun it from `main` @@ -53,7 +64,7 @@ matching `CHANGELOG.md` section, and creates or updates the GitHub Release. - **PR check** (`check-release.yml`): if the `[package]` version in `Cargo.toml` changes, `CHANGELOG.md` must contain a matching `## [X.Y.Z]` section. - **Tag check** (`publish.yml`): the tag (without `v`) must match the `[package]` version in `Cargo.toml`. -- **Publish guard** (`release.sh publish`): refuses to tag if the changelog section is missing. +- **Publish guard** (`release.sh publish`): refuses to tag if the changelog section is missing or the tag already exists. Together, these make it hard to ship a version without changelog notes or a GitHub Release. diff --git a/scripts/release.sh b/scripts/release.sh index 81e5081..65ad55d 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -20,11 +20,11 @@ Usage: ./scripts/release.sh prepare [patch|minor|major|X.Y.Z] ./scripts/release.sh publish -Workflow: - 1. Move notes from [Unreleased] in CHANGELOG.md (or add them there). - 2. ./scripts/release.sh prepare patch - 3. Merge the release PR. - 4. ./scripts/release.sh publish +Workflow (normally run by .github/workflows/cut-release.yml): + 1. Add notes under [Unreleased] in CHANGELOG.md. + 2. gh workflow run cut-release.yml -f bump=patch (runs `prepare`) + 3. The release PR auto-merges once CI and the review gate pass. + 4. Merging the release/vX.Y.Z PR runs `publish` in CI. Tag push triggers the crates.io publish and GitHub Release creation in CI. EOF @@ -164,9 +164,9 @@ cmd_prepare() { git push -u origin "$branch" gh pr create --base "$base" --head "$branch" \ --title "chore: release ${PKG_NAME} v${new}" \ - --body "Release **${PKG_NAME} v${new}**: version bumped in \`Cargo.toml\` and \`CHANGELOG.md\` updated. After merge, run \`./scripts/release.sh publish\` from a clean \`${base}\` checkout." + --body "Release **${PKG_NAME} v${new}**: version bumped in \`Cargo.toml\` and \`CHANGELOG.md\` updated. On merge, the \`Cut Release\` workflow tags \`${base}\` with \`v${new}\`, which publishes to crates.io and creates the GitHub Release." - echo "Prepared ${PKG_NAME} v${new}. Merge the PR, then run: ./scripts/release.sh publish" + echo "Prepared ${PKG_NAME} v${new}. Merging the PR tags and publishes it from CI." } cmd_publish() {