diff --git a/.github/actions/RELEASE.md b/.github/actions/RELEASE.md index e7ea8ae..c66a8a1 100644 --- a/.github/actions/RELEASE.md +++ b/.github/actions/RELEASE.md @@ -148,11 +148,16 @@ gh workflow run release-candidate.yml --ref main \ ``` 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. +tag, `origin/main~4`. It must be reachable from the default branch, **or** +sit on the latest `v*` release line (from the merge-base with the default +branch through that tag). Settlement leaves the tag off trunk, so a hotfix +of the current release is named as that tag (or its peeled SHA), not as a +commit further down the default branch. + +A side branch that never landed on trunk is still refused. So is an older +release line that a higher `v*` tag has already passed: the version must +still beat the newest tag, and those commits are outside the latest-line +window. Step 1 then works differently, and steps 3 and 4 are unchanged: @@ -174,9 +179,10 @@ Step 1 then works differently, and steps 3 and 4 are unchanged: 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. +usual. That covers two cases: an earlier commit on the current default +branch, and a hotfix of the latest tag (new `release-vX.Y.Z` cut there, +candidate PR aimed at that branch). It still does not patch an older line +that a higher tag has already passed. 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 diff --git a/.github/actions/release-candidate/README.md b/.github/actions/release-candidate/README.md index 48b06b3..b1c7d8e 100644 --- a/.github/actions/release-candidate/README.md +++ b/.github/actions/release-candidate/README.md @@ -16,7 +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. | +| `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`). It must be reachable from the default branch, or sit on the latest `v*` release line (from the merge-base with trunk through that tag), so a hotfix of the current tag works even though settlement left the tag off 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. | diff --git a/.github/actions/release-candidate/action.yml b/.github/actions/release-candidate/action.yml index c0c655b..ca12ba0 100644 --- a/.github/actions/release-candidate/action.yml +++ b/.github/actions/release-candidate/action.yml @@ -31,8 +31,10 @@ inputs: 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 + tag, `origin/main~3`). It must be reachable from the default branch, or + sit on the latest `v*` release line (from the merge-base with trunk + through that tag), so a hotfix of the current tag works even though + settlement left the tag off 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 @@ -188,8 +190,27 @@ runs: 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 + # Settlement leaves the latest tag off trunk (bump + dated + # changelog). A hotfix of that tag is still the current line, + # not a random side branch. + latest="$(git tag -l 'v*' --sort=-v:refname | head -n1 || true)" + on_release_line=0 + if [[ -n "$latest" ]]; then + latest_sha="$(git rev-parse --verify --quiet "${latest}^{commit}" || true)" + if [[ -n "$latest_sha" ]]; then + mb="$(git merge-base "$latest_sha" "origin/$default" || true)" + if [[ -n "$mb" ]] \ + && git merge-base --is-ancestor "$mb" "$base" \ + && git merge-base --is-ancestor "$base" "$latest_sha"; then + on_release_line=1 + fi + fi + fi + if [[ "$on_release_line" -ne 1 ]]; 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 is on the latest release line (${latest}), not on $default" fi echo "base commit: $BASE_COMMIT -> $base" fi diff --git a/.github/workflows/actions-test.yml b/.github/workflows/actions-test.yml index 6e217ea..da6e89d 100644 --- a/.github/workflows/actions-test.yml +++ b/.github/workflows/actions-test.yml @@ -614,9 +614,19 @@ jobs: 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" + echo "an off-trunk commit and nonsense are refused" + + - name: The latest release tag is an allowed base + run: | + set -euo pipefail + source "$SH/helpers.sh" + latest="$(git -C "$REPO" rev-parse 'v1.0.0^{commit}')" + guard 1.1.0 v1.0.0 + expect_output "base=$latest" + guard 1.1.0 "$latest" + expect_output "base=$latest" + echo "a hotfix of the current tag starts from that tag" - name: The rules that were already there still apply run: |