From 17166fb820304be721e444c8b9a9bb24e696143c Mon Sep 17 00:00:00 2001 From: tester Date: Fri, 11 Sep 2026 16:42:09 +0800 Subject: [PATCH] feat(release-candidate): release a commit that is not the trunk tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `main` moves while a release is being prepared, and until now the pipeline had no way to ship anything but its tip: propose is guarded to the default branch, the candidate PR merges there, and cut creates the release branch at that merge commit. `base_commit` names the commit to release instead. Propose resolves it (any commit-ish), refuses anything the default branch cannot reach so a release still ships code that went through trunk, creates `release-vX.Y.Z` at that commit itself, and opens the candidate PR against that branch — it cannot target the default branch, because merging it there would take the commits the release is deliberately leaving out. Checking the branch out first also means the bump and the changelog entry are drafted over what actually ships. Cut then has nothing to do, and settle is untouched: the release branch tip it wants is the merge of that PR. The default branch is left alone, so its version file keeps the old version and the next candidate syncs the changelog entry back from the tag, the way it already does after any release. Re-dispatching after a failed attempt reuses a release branch still sitting on exactly the requested commit; anything else is refused. The trunk-first flow is unchanged when `base_commit` is empty: the PR's base falls back to the checked-out branch and the guard keeps every rule it had. Tested in actions-test.yml by extracting the shipped steps from action.yml and running them against a throwaway repository shaped like a settled release line, with a fake `gh`. Co-Authored-By: Claude Opus 5 --- .github/actions/README.md | 2 +- .github/actions/RELEASE.md | 58 ++++- .github/actions/release-candidate/README.md | 12 +- .github/actions/release-candidate/action.yml | 106 ++++++++- .github/workflows/actions-test.yml | 235 +++++++++++++++++++ workflow-templates/release-candidate.yml | 14 ++ 6 files changed, 412 insertions(+), 15 deletions(-) diff --git a/.github/actions/README.md b/.github/actions/README.md index 70b212b..50666ed 100644 --- a/.github/actions/README.md +++ b/.github/actions/README.md @@ -19,7 +19,7 @@ a README whose input, output, step and error tables are generated from its | [`notify-lark`](notify-lark/README.md) | Standalone | Post a message to a Lark (Feishu) group through a custom-bot webhook. The default is a plain text message; `msg_type: post` sends rich text with a title, and `payload` sends any JSON you built yourself (interactive cards, mentions), untouched. If the bot has signature verification enabled, pass its signing `secret` and the request is signed with the documented timestamp + HMAC-SHA256 scheme. The webhook URL and secret go through the environment and are masked. A non-2xx response or a Lark error code fails the step unless `fail_on_error` is `false`; the response body is an output either way. Nothing is organisation-specific: the webhook is an input. | | [`pr-lint`](pr-lint/README.md) | Standalone | Lint a pull request. Currently validates that the PR title follows Conventional Commits, posting a sticky comment on failure and removing it once fixed; further PR-level lint steps can be added here over time. Run as a step inside a job the consumer names, so the resulting status-check context is that job name. | | [`release-assets`](release-assets/README.md) | [Release pipeline](RELEASE.md) | Attach files, plus a generated `SHA256SUMS`, to the GitHub Release for a tag. Re-runs replace assets of the same name (`--clobber`), so the step is idempotent. `dry_run` writes and prints `SHA256SUMS` but attaches nothing. Needs a token with `contents: write` on the repository (the job token is enough). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | -| [`release-candidate`](release-candidate/README.md) | [Release pipeline](RELEASE.md) | Start a release (trunk-first). `stage: propose` bumps the version file on the default branch, drafts this release's changelog entry (dated at settle) from the commits since the previous tag, syncs the previous release's entry from its tag, and opens a `chore/release-candidate-vX.Y.Z` PR; `stage: cut`, run when that PR merges, creates `release-vX.Y.Z` at the merge commit. No tag is created at either stage — tags come from release-publish, once, at settlement. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | +| [`release-candidate`](release-candidate/README.md) | [Release pipeline](RELEASE.md) | Start a release (trunk-first). `stage: propose` bumps the version file on the default branch, drafts this release's changelog entry (dated at settle) from the commits since the previous tag, syncs the previous release's entry from its tag, and opens a `chore/release-candidate-vX.Y.Z` PR; `stage: cut`, run when that PR merges, creates `release-vX.Y.Z` at the merge commit. No tag is created at either stage — tags come from release-publish, once, at settlement. `base_commit` releases a commit that is not the tip of the default branch: `propose` cuts the release branch there itself and points the candidate PR at it, leaving `cut` nothing to do. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | | [`release-publish`](release-publish/README.md) | [Release pipeline](RELEASE.md) | Publish a settled release. Normally runs when a `chore/release-settle-vX.Y.Z` PR merges into its release branch (or, with `commit` + `version` given, on an explicit commit that `release-settle` in `direct` mode just made): creates the annotated tag `vX.Y.Z` at the merge commit (exactly once — refuses if it exists, or if the branch moved after the settle PR was opened) and publishes the GitHub Release with the changelog section as notes. This is the only place in the release flow that creates a tag; merging the settle PR is the approval. The default branch's changelog catches up in the next release candidate PR — nothing is back-merged. The consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | | [`release-publish-rust-crates`](release-publish-rust-crates/README.md) | [Release pipeline](RELEASE.md) | Publish an explicit list of workspace crates to crates.io at the release version. Verifies every listed crate's manifest version first, skips crates already published at that version (so a re-run after a partial failure finishes the rest), publishes the remainder in one `cargo publish -p … -p …` invocation — Cargo orders by dependency and waits for the index between crates (requires Cargo ≥ 1.90) — then polls crates.io until every crate reports the version. `dry_run` runs `cargo publish --dry-run`: full packaging and build verification, nothing uploaded. Run inside a job that has checked out the release tag and installed the toolchain the crates need. Never use `--workspace`: a crate without `publish = false` that was never meant to be published would go out with it. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | | [`release-settle`](release-settle/README.md) | [Release pipeline](RELEASE.md) | Propose settling a release candidate: verify `commit` is the tip of the release branch and carries the expected version, generate release notes from the commits since the previous tag, write them into the changelog (stamping the date onto the candidate's `## vX.Y.Z` entry), and — in the default `pr` mode — open a `chore/release-settle-vX.Y.Z` PR onto the release branch; merging that PR is the settlement decision and release-publish then tags the merge commit once. In `direct` mode the dispatch itself is the decision, gated by the `release` environment the consumer puts on the settle job (optionally also by `settlers`); the changelog commit is pushed straight to the release branch (the app must be a bypass actor on that branch's ruleset), and release-publish runs immediately on that commit. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Needs `gh` and `python3` on the runner. Guide: .github/actions/RELEASE.md in megaeth-labs/.github. | diff --git a/.github/actions/RELEASE.md b/.github/actions/RELEASE.md index 5d104c5..e7ea8ae 100644 --- a/.github/actions/RELEASE.md +++ b/.github/actions/RELEASE.md @@ -40,7 +40,10 @@ The choices that shape it: default branch through an ordinary reviewed PR; the release branch is cut from that merge. Fixes for a release go to the release branch by PR and must also land on the default branch, because the next candidate is cut - from there. + from there. When the release is an earlier commit rather than the tip + (`base_commit`), the same reviewed PR lands on the release branch instead, + and the default branch is left alone — see [Releasing a commit that is not + the tip](#releasing-a-commit-that-is-not-the-tip). - **No release-candidate tags.** A tag is created exactly once, at settlement, on the commit that ships. Tags are immutable: the `v*` tag ruleset forbids updating or deleting them, and nothing but the app can @@ -134,6 +137,52 @@ decision; `release-publish.yml` then tags the merge commit and publishes the Release, refusing if the branch moved since the PR was made (the merge's first parent must be the settled SHA) or the PR was not opened by the app. +### Releasing a commit that is not the tip + +`main` moves while a release is being prepared. To ship an earlier commit, +name it: + +```sh +gh workflow run release-candidate.yml --ref main \ + -f version=1.2.3 -f base_commit="$(git rev-parse origin/main~4)" +``` + +The commit may be given as anything the checkout resolves — a full SHA, a +tag, `origin/main~4` — but it must be reachable from the default branch: a +release ships code that went through trunk. Note that `v*` release tags are +not reachable from it, because settlement commits the dated changelog on the +release branch; to release from an old release line, name a commit on the +default branch, not its tag. + +Step 1 then works differently, and steps 3 and 4 are unchanged: + +- `propose` creates `release-v1.2.3` at that commit itself, rather than + leaving it to `cut`, and opens the candidate PR **against that branch**. + The PR cannot target the default branch: merging it there would take the + commits this release is deliberately leaving out. +- The changelog entry is drafted over what actually ships, the commits from + the previous tag up to the named commit, not up to the tip. +- Merging the candidate PR puts the version bump and the entry on + `release-v1.2.3`. There is no step 2: the `cut` job does not fire, because + the PR's base is the release branch and the workflow's `pull_request` + trigger filters on the default branch. The `release branch` ruleset governs + this PR, so it needs its approving review and a squash merge, like any + other change to a release branch. +- **The default branch is left untouched.** Its version file keeps the old + version, and it gains no changelog entry until the next candidate, which + syncs the entry back from the tag the way it does after any release. The + next version must still be newer than `v1.2.3`. + +The version must be newer than the newest `v*` tag in the repository, as +usual, so this releases an earlier commit on the current line. It does not +patch an older line that a higher tag has already passed; there is no +backport flow. + +If an attempt fails after the branch was created — a broken `bump_command`, +say — re-dispatching the same version and commit reuses the branch, because +it is still sitting on exactly that commit with nothing merged into it. Any +other mismatch is refused, and the branch has to be deleted by hand. + ## Installing it in a repository Everything below was done for mega-agents, mega-evm, stateless-validator and @@ -267,6 +316,11 @@ toolchain it needs. Examples in use: | `direct` (the templates) | the dispatch, approved through the `release` environment | the environment's required reviewers; the app bypasses the release-branch ruleset. `settlers` (default `any`) may additionally name who can start a settle: comma-separated logins and/or `admin` (the dispatcher must have admin permission, checked with the job token) | | `pr` (the action default) | merging the settle PR | the release-branch ruleset requires a reviewed PR; `release-publish.yml` present on the release branch | +**Which commit is released** (`base_commit`, candidate only): empty, the +default, releases the tip of the default branch through the trunk-first flow. +A commit-ish releases that commit instead — see +[Releasing a commit that is not the tip](#releasing-a-commit-that-is-not-the-tip). + **Labels** (`pr_labels`, candidate and settle): for repositories whose label gates apply to the app's PRs. @@ -400,7 +454,7 @@ What each action does, in one line: | Action | Trigger in the consumer | Does | |---|---|---| -| `release-candidate` `stage: propose` | `workflow_dispatch` on the default branch | bumps `version_file`, runs `bump_command`, drafts this release's changelog entry under `## vX.Y.Z`, syncs the previous release's entry from its tag, opens `chore/release-candidate-X.Y.Z` | +| `release-candidate` `stage: propose` | `workflow_dispatch` on the default branch | bumps `version_file`, runs `bump_command`, drafts this release's changelog entry under `## vX.Y.Z`, syncs the previous release's entry from its tag, opens `chore/release-candidate-X.Y.Z`; with `base_commit`, cuts `release-vX.Y.Z` at that commit first and aims the PR there | | `release-candidate` `stage: cut` | that PR merging | creates `release-vX.Y.Z` at the merge commit | | `release-settle` | `workflow_dispatch` with version + tip SHA | guards, warns if the tip lacks a workflow the default branch has, regenerates the entry up to the tip and stamps the date; `direct`: commits it to the release branch and publishes at once; `pr`: opens `chore/release-settle-vX.Y.Z` (a re-run closes the previous settle PR and opens a fresh one) | | `release-publish` | the settle PR merging, or `release-settle` in direct mode | annotated tag at the commit (refuses if it exists or the branch drifted), GitHub Release with the entry as notes, marked latest | diff --git a/.github/actions/release-candidate/README.md b/.github/actions/release-candidate/README.md index 277bac6..48b06b3 100644 --- a/.github/actions/release-candidate/README.md +++ b/.github/actions/release-candidate/README.md @@ -3,7 +3,7 @@ `uses: megaeth-labs/.github/.github/actions/release-candidate@main` -Start a release (trunk-first). `stage: propose` bumps the version file on the default branch, drafts this release's changelog entry (dated at settle) from the commits since the previous tag, syncs the previous release's entry from its tag, and opens a `chore/release-candidate-vX.Y.Z` PR; `stage: cut`, run when that PR merges, creates `release-vX.Y.Z` at the merge commit. No tag is created at either stage — tags come from release-publish, once, at settlement. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. +Start a release (trunk-first). `stage: propose` bumps the version file on the default branch, drafts this release's changelog entry (dated at settle) from the commits since the previous tag, syncs the previous release's entry from its tag, and opens a `chore/release-candidate-vX.Y.Z` PR; `stage: cut`, run when that PR merges, creates `release-vX.Y.Z` at the merge commit. No tag is created at either stage — tags come from release-publish, once, at settlement. `base_commit` releases a commit that is not the tip of the default branch: `propose` cuts the release branch there itself and points the candidate PR at it, leaving `cut` nothing to do. Run as a step in a job the consumer owns; the consumer checks the repository out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. Family: [Release pipeline](../RELEASE.md). @@ -16,6 +16,7 @@ Family: [Release pipeline](../RELEASE.md). | `stage` | yes | | `propose` (on workflow_dispatch) or `cut` (on the candidate PR merging). | | `token` | yes | | Token that authors the PR and creates the branch. Must be a GitHub App installation token (e.g. the Maxwell app) so the PR triggers CI; the job's GITHUB_TOKEN does not. | | `version` | no | | Version to release, X.Y.Z or vX.Y.Z. Required for `propose`. | +| `base_commit` | no | | The commit to release, for `propose`, when it must not be the tip of the default branch. Any commit-ish the checkout can resolve (a full SHA, a tag, `origin/main~3`), and it must be reachable from the default branch — a release still ships code that went through trunk. Empty (the default) is the trunk-first flow: the candidate PR merges into the default branch and `cut` creates the release branch at that merge commit. Set, `propose` creates the release branch at this commit and opens the candidate PR against that branch, because merging it into the default branch would take the commits this release is leaving out; the default branch is then untouched and `cut` never runs. The version must still be newer than the newest `v*` tag in the repository. | | `version_file` | yes | | File holding the version (VERSION, Cargo.toml, pyproject.toml, package.json). | | `version_pattern` | no | `plain` | How the version is stored in `version_file`: plain, toml or json. | | `bump_command` | no | | Optional shell command run after `version_file` is rewritten, for anything else that must move with the version: lockfiles (`cargo update --workspace`), path-dependency versions, generated files. Runs with `OLD_VERSION` and `NEW_VERSION` in the environment, in the repository root, under `bash -euo pipefail`. Whatever it changes is committed with the bump. Install any toolchain it needs in the calling job before this action. | @@ -34,7 +35,8 @@ Family: [Release pipeline](../RELEASE.md). |---|---| | `version` | Normalised X.Y.Z. | | `pr_url` | `propose`: URL of the candidate PR. | -| `release_branch` | `cut`: the release branch created. | +| `release_branch` | The release branch: created by `cut`, or by `propose` when `base_commit` is set. | +| `base_commit` | `propose` with `base_commit`: that commit-ish resolved to a full SHA. | ## What it runs @@ -43,6 +45,7 @@ Family: [Release pipeline](../RELEASE.md). 1. Resolve version 1. Configure git auth 1. Guard (propose) *(only if `inputs.stage == 'propose'`)* +1. Cut the release branch at the base commit (propose) *(only if `inputs.stage == 'propose' && inputs.base_commit != ''`)* 1. Bump version file *(only if `inputs.stage == 'propose'`)* 1. Run bump command *(only if `inputs.stage == 'propose' && inputs.bump_command != ''`)* 1. Draft changelog *(only if `inputs.stage == 'propose' && inputs.changelog_file != ''`)* @@ -60,12 +63,13 @@ Family: [Release pipeline](../RELEASE.md). - `release candidates start from the default branch ($default); this run is on $GITHUB_REF_NAME` - `$VERSION is not newer than the latest tag ${latest:-}` - `tag v$VERSION already exists` -- `branch ${PREFIX}${VERSION} already exists` +- `base_commit $BASE_COMMIT is not a commit in this checkout; check the repository out with fetch-depth: 0` +- `base_commit $base is not reachable from the default branch ($default); a release must ship code that landed on trunk` +- `branch $branch already exists` - `after bump_command, $FILE reads $actual, expected $NEW_VERSION` - `cut runs on a merged candidate PR (pull_request closed, merged == true)` - `candidate PR #$PR_NUMBER was opened by` - `$FILE at $MERGE_SHA says $actual, expected $VERSION` -- `branch $branch already exists` ## Example diff --git a/.github/actions/release-candidate/action.yml b/.github/actions/release-candidate/action.yml index cc435d8..c0c655b 100644 --- a/.github/actions/release-candidate/action.yml +++ b/.github/actions/release-candidate/action.yml @@ -6,8 +6,11 @@ description: >- tag, and opens a `chore/release-candidate-vX.Y.Z` PR; `stage: cut`, run when that PR merges, creates `release-vX.Y.Z` at the merge commit. No tag is created at either stage — tags come from release-publish, once, at - settlement. Run as a step in a job the consumer owns; the consumer checks the - repository out first (`fetch-depth: 0`, `persist-credentials: false`). + settlement. `base_commit` releases a commit that is not the tip of the + default branch: `propose` cuts the release branch there itself and points the + candidate PR at it, leaving `cut` nothing to do. Run as a step in a job the + consumer owns; the consumer checks the repository out first + (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. inputs: @@ -24,6 +27,21 @@ inputs: description: "Version to release, X.Y.Z or vX.Y.Z. Required for `propose`." required: false default: "" + base_commit: + description: >- + The commit to release, for `propose`, when it must not be the tip of the + default branch. Any commit-ish the checkout can resolve (a full SHA, a + tag, `origin/main~3`), and it must be reachable from the default branch + — a release still ships code that went through trunk. Empty (the + default) is the trunk-first flow: the candidate PR merges into the + default branch and `cut` creates the release branch at that merge commit. + Set, `propose` creates the release branch at this commit and opens the + candidate PR against that branch, because merging it into the default + branch would take the commits this release is leaving out; the default + branch is then untouched and `cut` never runs. The version must still be + newer than the newest `v*` tag in the repository. + required: false + default: "" version_file: description: "File holding the version (VERSION, Cargo.toml, pyproject.toml, package.json)." required: true @@ -81,8 +99,11 @@ outputs: description: "`propose`: URL of the candidate PR." value: ${{ steps.pr.outputs.pull-request-url }} release_branch: - description: "`cut`: the release branch created." - value: ${{ steps.cut.outputs.release_branch }} + description: "The release branch: created by `cut`, or by `propose` when `base_commit` is set." + value: ${{ steps.cut.outputs.release_branch || steps.branch.outputs.release_branch }} + base_commit: + description: "`propose` with `base_commit`: that commit-ish resolved to a full SHA." + value: ${{ steps.guard.outputs.base }} runs: using: composite @@ -130,11 +151,13 @@ runs: # ---------------------------------------------------------------- propose - name: Guard (propose) if: inputs.stage == 'propose' + id: guard shell: bash env: GH_TOKEN: ${{ inputs.token }} VERSION: ${{ steps.version.outputs.version }} PREFIX: ${{ inputs.release_branch_prefix }} + BASE_COMMIT: ${{ inputs.base_commit }} TOOLS: ${{ github.action_path }}/../release-tools/release_tools.py run: | set -euo pipefail @@ -152,9 +175,63 @@ runs: if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null; then echo "::error::tag v$VERSION already exists"; exit 1 fi - if git ls-remote --exit-code --heads origin "refs/heads/${PREFIX}${VERSION}" >/dev/null; then - echo "::error::branch ${PREFIX}${VERSION} already exists"; exit 1 + branch="${PREFIX}${VERSION}" + # An explicit commit is the release. Resolve it here so everything + # downstream works on a full SHA, and refuse anything the default + # branch cannot reach: a release ships code that went through trunk. + base="" + if [[ -n "$BASE_COMMIT" ]]; then + git fetch --quiet origin "+refs/heads/$default:refs/remotes/origin/$default" + base="$(git rev-parse --verify --quiet "${BASE_COMMIT}^{commit}" || true)" + if [[ -z "$base" ]]; then + echo "::error::base_commit $BASE_COMMIT is not a commit in this checkout; check the repository out with fetch-depth: 0" + exit 1 + fi + if ! git merge-base --is-ancestor "$base" "origin/$default"; then + echo "::error::base_commit $base is not reachable from the default branch ($default); a release must ship code that landed on trunk" + exit 1 + fi + echo "base commit: $BASE_COMMIT -> $base" + fi + # The release branch must not exist yet. One exception: with an + # explicit commit, propose creates that branch before it opens the PR, + # so a re-dispatch after a failed attempt finds its own branch still + # sitting on the requested commit, with nothing merged into it. + existing="$(git ls-remote --heads origin "refs/heads/$branch" | cut -f1)" + if [[ -n "$existing" ]]; then + if [[ -n "$base" && "$existing" == "$base" ]]; then + echo "::notice title=Reusing the release branch::$branch already exists at $base, from an earlier attempt at this candidate" + else + echo "::error::branch $branch already exists"; exit 1 + fi + fi + { + echo "base=$base" + echo "branch=$branch" + } >> "$GITHUB_OUTPUT" + + # With an explicit base commit the candidate PR cannot target the default + # branch: merging it there would take the commits this release is + # deliberately leaving out. So the release branch is cut here instead of at + # `cut`, and the PR targets it. Checking it out also means the bump and the + # changelog below are drafted over what this release contains. + - name: Cut the release branch at the base commit (propose) + if: inputs.stage == 'propose' && inputs.base_commit != '' + id: branch + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + BRANCH: ${{ steps.guard.outputs.branch }} + BASE: ${{ steps.guard.outputs.base }} + run: | + set -euo pipefail + if ! git ls-remote --exit-code --heads origin "refs/heads/$BRANCH" >/dev/null; then + gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$BASE" >/dev/null + echo "created $BRANCH at $BASE" fi + git checkout --quiet -B "$BRANCH" "$BASE" + echo "release_branch=$BRANCH" >> "$GITHUB_OUTPUT" + echo "candidate PR will target $BRANCH" - name: Bump version file if: inputs.stage == 'propose' @@ -263,6 +340,9 @@ runs: with: token: ${{ inputs.token }} branch: chore/release-candidate-${{ steps.version.outputs.version }} + # Empty means the branch this job checked out, i.e. the default + # branch; set, it is the release branch cut above. + base: ${{ steps.branch.outputs.release_branch }} delete-branch: true commit-message: "chore(release): candidate v${{ steps.version.outputs.version }}" committer: "${{ inputs.git_user_name }} <${{ inputs.git_user_email }}>" @@ -272,8 +352,9 @@ runs: body: | Release candidate **v${{ steps.version.outputs.version }}** (was ${{ steps.bump.outputs.old }}). - Merging this PR creates the release branch `${{ inputs.release_branch_prefix }}${{ steps.version.outputs.version }}` - at the merge commit. It creates **no tag**; the tag is cut once, at settlement, by `release-settle` → `release-publish`. + ${{ steps.branch.outputs.release_branch && format('Cut from `{0}`, which is not the tip of the default branch: `{1}` already exists at that commit, and merging this PR puts the version bump and the changelog entry on it. The default branch is left untouched — the next candidate syncs this entry back from the tag.', steps.guard.outputs.base, steps.branch.outputs.release_branch) || format('Merging this PR creates the release branch `{0}{1}` at the merge commit.', inputs.release_branch_prefix, steps.version.outputs.version) }} + + It creates **no tag**; the tag is cut once, at settlement, by `release-settle` → `release-publish`. Changelog: this release's entry drafted under `## v${{ steps.version.outputs.version }}` (settle stamps the date on the release branch); previous release's entry synced from its tag: ${{ steps.changelog.outputs.synced || 'n/a' }}. @@ -295,6 +376,7 @@ runs: MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_AUTHOR: ${{ github.event.pull_request.user.login }} + BASE_REF: ${{ github.event.pull_request.base.ref }} EXPECTED_AUTHOR: ${{ inputs.pr_author }} TOOLS: ${{ github.action_path }}/../release-tools/release_tools.py run: | @@ -311,6 +393,14 @@ runs: echo "::error::$FILE at $MERGE_SHA says $actual, expected $VERSION"; exit 1 fi branch="${PREFIX}${VERSION}" + # A candidate that targeted the release branch was proposed with an + # explicit base commit: propose already cut the branch there, this + # merge landed the bump on it, and there is nothing left to create. + if [[ "$BASE_REF" == "$branch" ]]; then + echo "release_branch=$branch" >> "$GITHUB_OUTPUT" + echo "::notice title=Release branch already cut::$branch was created by propose at the requested base commit and this candidate merged into it" + exit 0 + fi if git ls-remote --exit-code --heads origin "refs/heads/$branch" >/dev/null; then echo "::error::branch $branch already exists"; exit 1 fi diff --git a/.github/workflows/actions-test.yml b/.github/workflows/actions-test.yml index 5e37de7..6e217ea 100644 --- a/.github/workflows/actions-test.yml +++ b/.github/workflows/actions-test.yml @@ -441,6 +441,241 @@ jobs: [[ "$SKIP" == "false" && -z "$PR" ]] || { echo "skip-check=$SKIP pr_number=$PR"; exit 1; } echo "false outside a queue, as documented" + # release-candidate's propose shell runs at the start of every release in + # every repository, and a real run needs an app token and a live remote. + # Drive the shipped script instead — extracted from action.yml, never copied + # — against a throwaway repository shaped like a settled release line, with + # a fake `gh` for the default-branch lookup and the ref call. + release-candidate: + runs-on: ubuntu-24.04 + permissions: + contents: read + timeout-minutes: 5 + env: + TOOLS: ${{ github.workspace }}/.github/actions/release-tools/release_tools.py + ACTION: ${{ github.workspace }}/.github/actions/release-candidate/action.yml + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # `runner` is not a context a job-level `env:` may read, so the paths + # the steps below share are set here instead. + - name: Where the rehearsal lives + run: | + set -euo pipefail + { + echo "REPO=$RUNNER_TEMP/repo" + echo "SH=$RUNNER_TEMP/sh" + echo "OUT=$RUNNER_TEMP/step-output" + } >> "$GITHUB_ENV" + + - name: The shell under test, taken from the action + run: | + set -euo pipefail + python3 -m pip install --quiet pyyaml + mkdir -p "$SH" + python3 - "$ACTION" "$SH" <<'PY' + import pathlib, sys, yaml + + want = { + "Guard (propose)": "guard.sh", + "Cut the release branch at the base commit (propose)": "cut-branch.sh", + "Draft changelog": "changelog.sh", + } + steps = yaml.safe_load(open(sys.argv[1]))["runs"]["steps"] + found = {s["name"]: s["run"] for s in steps if s.get("name") in want} + missing = sorted(set(want) - set(found)) + if missing: + sys.exit(f"action.yml has no step named {missing}; this job drives them by name") + for name, script in found.items(): + pathlib.Path(sys.argv[2], want[name]).write_text(script) + print(f"extracted {name!r} -> {want[name]}") + PY + cat > "$SH/helpers.sh" <<'SH' + # Run the guard the way the action's step does. + guard() { # [base_commit] [ref_name] + : > "$OUT" + ( cd "$REPO" && env \ + GH_TOKEN=fake \ + GITHUB_REPOSITORY=megaeth-labs/fake \ + GITHUB_REF_NAME="${3:-main}" \ + GITHUB_OUTPUT="$OUT" \ + VERSION="$1" \ + PREFIX=release-v \ + BASE_COMMIT="${2:-}" \ + TOOLS="$TOOLS" \ + bash "$SH/guard.sh" ) + } + + expect_output() { # + grep -qxF "$1" "$OUT" || { echo "no '$1' in the step output:"; cat "$OUT"; exit 1; } + } + + refuse() { # [ref_name] + local log="$RUNNER_TEMP/refused.log" + if guard "$1" "$2" "${4:-main}" > "$log" 2>&1; then + echo "expected a refusal for version=$1 base=$2"; cat "$log"; exit 1 + fi + grep -qF "$3" "$log" || { echo "refused, but not for '$3':"; cat "$log"; exit 1; } + } + SH + + - name: A fake gh + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bin" + cat > "$RUNNER_TEMP/bin/gh" <<'SH' + #!/usr/bin/env bash + # Only what the shell under test calls: the default branch, and + # creating a ref, which here is a push into the throwaway remote. + set -euo pipefail + case "${1:-}" in + repo) + echo "main" + ;; + api) + ref=""; sha="" + while [[ $# -gt 0 ]]; do + if [[ "$1" == "-f" ]]; then + case "${2:-}" in + ref=*) ref="${2#ref=}" ;; + sha=*) sha="${2#sha=}" ;; + esac + shift 2 || break + else + shift + fi + done + if [[ -n "$ref" && -n "$sha" ]]; then + git push --quiet origin "$sha:$ref" + fi + ;; + esac + SH + chmod +x "$RUNNER_TEMP/bin/gh" + echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" + + - name: A repository shaped like a settled release line + run: | + set -euo pipefail + git config --global user.name tester + git config --global user.email tester@example.invalid + git config --global init.defaultBranch main + git init --quiet --bare "$REPO.git" + git clone --quiet "$REPO.git" "$REPO" 2>/dev/null + cd "$REPO" + echo 1.0.0 > VERSION + printf '# Changelog\n\n## v1.0.0 (2024-01-01)\n\n- shipped in 1.0.0\n' > CHANGELOG.md + git add -A && git commit --quiet -m "feat: the first release" + git commit --quiet --allow-empty -m "feat: two" + git commit --quiet --allow-empty -m "fix: three" + git rev-parse HEAD > "$RUNNER_TEMP/base" + git commit --quiet --allow-empty -m "feat: not shipping this" + git commit --quiet --allow-empty -m "feat: nor this" + git push --quiet -u origin main + # v1.0.0 sits on its own release branch, off trunk, exactly where + # settlement leaves it. + git checkout --quiet -b release-v1.0.0 "$(git rev-list --max-parents=0 HEAD)" + git commit --quiet --allow-empty -m "chore(release): settle v1.0.0" + git tag -a v1.0.0 -m v1.0.0 + git push --quiet origin release-v1.0.0 --tags + git checkout --quiet main + git branch --quiet -D release-v1.0.0 + # A commit the default branch cannot reach. + git checkout --quiet -b side + git commit --quiet --allow-empty -m "feat: never reviewed" + git rev-parse HEAD > "$RUNNER_TEMP/unreachable" + git checkout --quiet main + echo "base commit $(cat "$RUNNER_TEMP/base")" + + - name: The trunk-first path is unchanged + run: | + set -euo pipefail + source "$SH/helpers.sh" + guard 1.1.0 + expect_output "base=" + expect_output "branch=release-v1.1.0" + echo "no base commit still means the default-branch flow" + + - name: An explicit commit resolves, however it was written + run: | + set -euo pipefail + source "$SH/helpers.sh" + base="$(cat "$RUNNER_TEMP/base")" + guard 1.1.0 "$base" + expect_output "base=$base" + guard 1.1.0 "origin/main~2" + expect_output "base=$base" + echo "a full SHA and a relative ref both resolve to $base" + + - name: A commit the default branch cannot reach is refused + run: | + set -euo pipefail + source "$SH/helpers.sh" + refuse 1.1.0 "$(cat "$RUNNER_TEMP/unreachable")" "not reachable from the default branch" + refuse 1.1.0 v1.0.0 "not reachable from the default branch" + refuse 1.1.0 0000000000000000000000000000000000000000 "is not a commit in this checkout" + echo "an off-trunk commit, a release tag and nonsense are all refused" + + - name: The rules that were already there still apply + run: | + set -euo pipefail + source "$SH/helpers.sh" + refuse 1.1.0 "" "release candidates start from the default branch" some-branch + refuse 1.1.0 "$(cat "$RUNNER_TEMP/base")" "release candidates start from the default branch" some-branch + refuse 0.9.0 "" "is not newer than the latest tag" + refuse 0.9.0 "$(cat "$RUNNER_TEMP/base")" "is not newer than the latest tag" + echo "the default-branch and version rules come first, with or without a base commit" + + - name: The release branch is cut at the commit, and the notes stop there + run: | + set -euo pipefail + source "$SH/helpers.sh" + base="$(cat "$RUNNER_TEMP/base")" + guard 1.1.0 "$base" + : > "$OUT" + ( cd "$REPO" && env \ + GH_TOKEN=fake \ + GITHUB_REPOSITORY=megaeth-labs/fake \ + GITHUB_OUTPUT="$OUT" \ + BRANCH=release-v1.1.0 \ + BASE="$base" \ + bash "$SH/cut-branch.sh" ) + expect_output "release_branch=release-v1.1.0" + [[ "$(git -C "$REPO" rev-parse HEAD)" == "$base" ]] \ + || { echo "HEAD is not the base commit"; exit 1; } + [[ "$(git -C "$REPO" symbolic-ref --short HEAD)" == "release-v1.1.0" ]] \ + || { echo "the job is not on the release branch"; exit 1; } + [[ "$(git -C "$REPO" ls-remote --heads origin refs/heads/release-v1.1.0 | cut -f1)" == "$base" ]] \ + || { echo "the remote branch is not at the base commit"; exit 1; } + # The changelog step itself is untouched by this change; run it to + # prove the entry covers what ships, not what is on the trunk tip. + ( cd "$REPO" && env \ + VERSION=1.1.0 \ + CHANGELOG=CHANGELOG.md \ + GITHUB_REPOSITORY=megaeth-labs/fake \ + GITHUB_OUTPUT="$OUT" \ + TOOLS="$TOOLS" \ + bash "$SH/changelog.sh" ) + entry="$(sed -n '/^## v1.1.0/,/^## v1.0.0/p' "$REPO/CHANGELOG.md")" + echo "$entry" + grep -q '^- two' <<< "$entry" || { echo "a commit that ships is missing"; exit 1; } + grep -q '^- three' <<< "$entry" || { echo "a commit that ships is missing"; exit 1; } + ! grep -q 'not shipping' <<< "$entry" || { echo "a commit past the base is in the entry"; exit 1; } + echo "release-v1.1.0 cut at $base, notes stop there" + + - name: A re-dispatch reuses its own branch, and nothing else + run: | + set -euo pipefail + source "$SH/helpers.sh" + base="$(cat "$RUNNER_TEMP/base")" + guard 1.1.0 "$base" + expect_output "base=$base" + refuse 1.1.0 "origin/main~1" "already exists" + refuse 1.1.0 "" "already exists" + echo "the same commit is reused; a different one, and the trunk flow, are refused" + # The generated parts of every action README (inputs, outputs, steps, # errors) and the catalogue come from action.yml; stale docs fail here. docs: diff --git a/workflow-templates/release-candidate.yml b/workflow-templates/release-candidate.yml index ed541e0..125553f 100644 --- a/workflow-templates/release-candidate.yml +++ b/workflow-templates/release-candidate.yml @@ -6,6 +6,11 @@ name: Release Candidate # PR merges, the `cut` job creates `release-vX.Y.Z` at the merge commit. No # tag is created here — see release-settle / release-publish. # +# To release a commit that is not the tip of the default branch, dispatch with +# `base_commit` as well: `propose` creates `release-vX.Y.Z` at that commit and +# aims the candidate PR at it, so that PR never touches the default branch and +# the `cut` job below does not run. +# # Guide: https://github.com/megaeth-labs/.github/blob/main/.github/actions/RELEASE.md on: @@ -15,6 +20,11 @@ on: description: "Version to release (X.Y.Z)" required: true type: string + base_commit: + description: "Commit to release (blank: the tip of the default branch)" + required: false + type: string + default: "" pull_request: types: [closed] branches: [$default-branch] @@ -51,6 +61,7 @@ jobs: stage: propose token: ${{ steps.app-token.outputs.token }} version: ${{ inputs.version }} + base_commit: ${{ inputs.base_commit }} version_file: VERSION version_pattern: plain changelog_file: CHANGELOG.md @@ -58,6 +69,9 @@ jobs: # bump_command: cargo update --workspace # (install the toolchain it needs in this job, before this step) + # Only candidate PRs that targeted the default branch reach this job (the + # `branches:` filter above): one proposed with `base_commit` merges into the + # release branch propose already cut. cut: if: >- github.event_name == 'pull_request' &&