Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions .github/workflows/cut-release.yml
Original file line number Diff line number Diff line change
@@ -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=<patch|minor|major|X.Y.Z>`
# 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
Comment on lines +34 to +36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Move concurrency from the workflow level to the publish and prepare jobs (not blocking).

The workflow-level group applies to every closed PR on main, including runs where both jobs skip. GitHub keeps one running and one pending run per group. A newer pending run cancels the older pending run, even with cancel-in-progress: false. A release-PR merge run can wait behind another close event. A third close event then cancels the release run, and no tag is pushed. Job-level concurrency only applies to jobs that pass their if, so unrelated PR closes never enter the group.


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 }}"
Comment on lines +78 to +80

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Pass inputs.bump through env instead of ${{ }} in the script (not blocking).

The runner expands ${{ inputs.bump }} into the shell source before release.sh validates the value. A value with " or $(...) runs as shell code with the App token in the environment. GitHub's script-injection hardening guide recommends an intermediate environment variable.

Suggested change
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: ./scripts/release.sh prepare "${{ inputs.bump }}"
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BUMP: ${{ inputs.bump }}
run: ./scripts/release.sh prepare "$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
37 changes: 24 additions & 13 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`
Expand All @@ -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.

Expand Down
14 changes: 7 additions & 7 deletions scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Loading