From 8ddc4645d9ad44ce46cd1f7998e8aa78bc54a70c Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Tue, 3 Nov 2026 15:09:45 -0600 Subject: [PATCH 1/4] ci: prepare, tag and publish releases on release/. branches prepare-release and tag-release take a `branch` input (default master). A release branch takes only its own line's versions, and master refuses a line that already has one. Master's cmake-version.py validates the inputs and reads the target branch from a separate refs/heads/ checkout, so no target-branch code runs. tag-release also refuses a branch without a pull_request rule. release.yaml accepts a tag on master or its version's release branch, and gives the latest label to the highest published stable version. PRs into release/* run the master checks. Adds unit tests for cmake-version.py in a new release-tooling job. Change-Id: If95aa740208950352ead33f612e6e01659f7693e --- .github/scripts/cmake-version.py | 63 ++++++++- .github/scripts/test_cmake_version.py | 187 +++++++++++++++++++++++++ .github/workflows/build-macos.yaml | 3 + .github/workflows/build.yaml | 31 +++- .github/workflows/prepare-release.yaml | 81 +++++++++-- .github/workflows/release.yaml | 59 ++++++-- .github/workflows/tag-release.yaml | 75 ++++++++-- docs/release-workflow.md | 65 +++++++-- 8 files changed, 504 insertions(+), 60 deletions(-) create mode 100644 .github/scripts/test_cmake_version.py diff --git a/.github/scripts/cmake-version.py b/.github/scripts/cmake-version.py index 0625cd27a..afa3c2ebc 100755 --- a/.github/scripts/cmake-version.py +++ b/.github/scripts/cmake-version.py @@ -3,7 +3,7 @@ The release workflows are the only consumers: ``prepare-release.yaml`` writes the requested version into the tree, ``tag-release.yaml`` reads it back to assert that -master's HEAD really carries the version being tagged, and ``release.yaml`` reads +the target branch's HEAD really carries the version being tagged, and ``release.yaml`` reads it to cross-check the tag against the tree. All three go through this one parser so those assertions can never drift apart. @@ -25,12 +25,19 @@ prepare-release.yaml, tag-release.yaml and release.yaml routes through this one implementation of :data:`VERSION_RE`. +``validate --branch`` adds the branch rule prepare-release.yaml and +tag-release.yaml enforce: ``master`` carries any version, and a +``release/.`` branch carries only its own line's versions. +``release-branch`` prints the branch of a version's line, so those workflows can +also refuse master for a line whose release branch already exists. + Usage:: cmake-version.py read [--cmakelists PATH] cmake-version.py write [--cmakelists PATH] - cmake-version.py validate + cmake-version.py validate [--branch BRANCH] cmake-version.py validate --tag + cmake-version.py release-branch """ from __future__ import annotations @@ -42,10 +49,17 @@ #: ``..`` with an optional ``-`` of dot-separated #: alphanumerics -- deliberately conservative so a typo cannot reach the tree. -VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$") +#: Anchored with ``\Z`` rather than ``$``, which would also accept a trailing newline. +VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?\Z") DEFAULT_CMAKELISTS = Path(__file__).resolve().parents[2] / "CMakeLists.txt" +#: The development branch. Any version may be prepared and tagged on it. +DEVELOPMENT_BRANCH = "master" + +#: Prefix of a release branch, ``release/.``. +RELEASE_BRANCH_PREFIX = "release/" + def _field_re(name: str) -> re.Pattern[str]: """Build the anchored matcher for one ``set( )`` line. @@ -84,6 +98,32 @@ def validate_version(version: str, tag: bool = False) -> str: return bare +def release_branch(version: str) -> str: + """Return the ``release/.`` branch of ``version``'s line. + + ``version`` must already have passed :func:`validate_version`. + """ + major, minor = VERSION_RE.match(version).group(1, 2) + return f"{RELEASE_BRANCH_PREFIX}{major}.{minor}" + + +def validate_branch(branch: str, version: str) -> None: + """Assert ``version`` may be prepared and tagged on ``branch``. + + :data:`DEVELOPMENT_BRANCH` takes any version. A release branch takes only its + own line, so ``1.2.0`` can never be cut from ``release/1.1``. Any other + branch is refused. + """ + if branch == DEVELOPMENT_BRANCH: + return + expected = release_branch(version) + if branch != expected: + raise SystemExit( + f"cmake-version: version '{version}' is prepared and tagged on " + f"'{DEVELOPMENT_BRANCH}' or '{expected}', not '{branch}'" + ) + + def read_version(text: str) -> str: """Return the ``VERSION_FULL`` string composed from the ``set(VERSION_*)`` lines.""" parts = {} @@ -129,7 +169,7 @@ def write_version(text: str, version: str) -> str: def main() -> int: """Parse arguments and dispatch to the read or write mode.""" parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("mode", choices=("read", "write", "validate")) + parser.add_argument("mode", choices=("read", "write", "validate", "release-branch")) parser.add_argument( "version", nargs="?", help="version to write or validate" ) @@ -142,6 +182,11 @@ def main() -> int: metavar="TAG", help="validate mode: the vX.Y.Z[-suffix] tag to check; prints its bare version", ) + parser.add_argument( + "--branch", + metavar="BRANCH", + help="validate mode: also assert the version may be prepared and tagged on BRANCH", + ) parser.add_argument("--cmakelists", type=Path, default=DEFAULT_CMAKELISTS) args = parser.parse_args() @@ -150,6 +195,8 @@ def main() -> int: # also depend on a readable CMakeLists.txt. if args.mode == "validate": if args.tag: + if args.branch is not None: + raise SystemExit("cmake-version: --branch applies to a version, not --tag") # The tag form is consumed as `version=$(... validate --tag "$TAG")`, # so it prints the bare version; the plain form is a pure exit-status # check and stays silent on success. @@ -160,6 +207,14 @@ def main() -> int: "cmake-version: validate mode requires a version argument or --tag" ) validate_version(args.version) + if args.branch is not None: + validate_branch(args.branch, args.version) + return 0 + + if args.mode == "release-branch": + if not args.version: + raise SystemExit("cmake-version: release-branch mode requires a version argument") + print(release_branch(validate_version(args.version))) return 0 text = args.cmakelists.read_text() diff --git a/.github/scripts/test_cmake_version.py b/.github/scripts/test_cmake_version.py new file mode 100644 index 000000000..e316dc599 --- /dev/null +++ b/.github/scripts/test_cmake_version.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Unit tests for ``cmake-version.py``, the release workflows' version parser. + +Run with ``python3 -m unittest discover -s .github/scripts -p 'test_*.py'``. Kept +identical in wire-sysio and wire-cdt, like the parser it tests. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +#: The parser under test. Its hyphenated file name cannot be imported by name. +SCRIPT = Path(__file__).resolve().with_name("cmake-version.py") + +_spec = importlib.util.spec_from_file_location("cmake_version", SCRIPT) +cmake_version = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(cmake_version) + +#: A root CMakeLists.txt version block shaped like the real ones. +CMAKELISTS = """cmake_minimum_required(VERSION 3.16) + +set(VERSION_MAJOR 1) +set(VERSION_MINOR 0) +set(VERSION_PATCH 0) +set(VERSION_SUFFIX "") + +project(example) +""" + + +def run_script(*args: str) -> subprocess.CompletedProcess[str]: + """Run the parser the way the workflows do, capturing its output.""" + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + capture_output=True, + text=True, + check=False, + ) + + +class ValidateVersionTests(unittest.TestCase): + """The ``..[-]`` grammar.""" + + def test_accepts_stable_and_suffixed_versions(self) -> None: + for version in ("1.0.0", "1.1.0-rc1", "1.0.0-dev", "10.20.30", "1.1.0-rc.2"): + with self.subTest(version=version): + self.assertEqual(cmake_version.validate_version(version), version) + + def test_rejects_malformed_versions(self) -> None: + for version in ("", "1.0", "1.0.0.0", "v1.0.0", "1.0.0-", "1.0.0-rc-1", "1.0.0-rc..1", "1.x.0", "1.0.0\n"): + with self.subTest(version=version), self.assertRaises(SystemExit): + cmake_version.validate_version(version) + + def test_tag_form_returns_the_bare_version(self) -> None: + self.assertEqual(cmake_version.validate_version("v1.1.0-rc1", tag=True), "1.1.0-rc1") + + def test_tag_form_requires_the_leading_v(self) -> None: + with self.assertRaises(SystemExit): + cmake_version.validate_version("1.1.0", tag=True) + + +class ReleaseBranchTests(unittest.TestCase): + """``release_branch`` names a version's ``release/.`` line.""" + + def test_names_the_major_minor_line(self) -> None: + self.assertEqual(cmake_version.release_branch("1.1.0"), "release/1.1") + self.assertEqual(cmake_version.release_branch("1.1.3-rc2"), "release/1.1") + self.assertEqual(cmake_version.release_branch("10.20.0"), "release/10.20") + + +class ValidateBranchTests(unittest.TestCase): + """Which branch may carry which version.""" + + def test_master_takes_any_version(self) -> None: + for version in ("1.0.0", "1.1.0-rc1", "2.0.0-dev", "1.1.7"): + with self.subTest(version=version): + cmake_version.validate_branch("master", version) + + def test_release_branch_takes_its_own_line(self) -> None: + for version in ("1.1.0", "1.1.0-rc1", "1.1.0-dev", "1.1.7"): + with self.subTest(version=version): + cmake_version.validate_branch("release/1.1", version) + + def test_release_branch_refuses_other_lines(self) -> None: + # 1.10.0 and 11.1.0 guard against a prefix match on "release/1.1". + for version in ("1.2.0", "1.0.1", "2.1.0", "1.10.0", "11.1.0", "0.1.0"): + with self.subTest(version=version), self.assertRaises(SystemExit): + cmake_version.validate_branch("release/1.1", version) + + def test_refuses_branches_that_are_neither_master_nor_a_release_line(self) -> None: + for branch in ( + "", + "main", + "develop", + "feature/release-1.1", + "release/prep-v1.1.0", + "release/1", + "release/v1.1", + "release/1.1/", + "release/1.1.0", + "refs/heads/master", + "refs/heads/release/1.1", + ): + with self.subTest(branch=branch), self.assertRaises(SystemExit): + cmake_version.validate_branch(branch, "1.1.0") + + +class CommandLineTests(unittest.TestCase): + """``validate`` as the workflows invoke it.""" + + def test_validate_with_matching_branch_succeeds_silently(self) -> None: + for branch in ("master", "release/1.1"): + with self.subTest(branch=branch): + result = run_script("validate", "1.1.0-rc1", "--branch", branch) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + + def test_validate_with_other_line_names_the_expected_branch(self) -> None: + result = run_script("validate", "1.2.0", "--branch", "release/1.1") + self.assertNotEqual(result.returncode, 0) + self.assertIn("release/1.2", result.stderr) + + def test_validate_without_branch_checks_only_the_grammar(self) -> None: + self.assertEqual(run_script("validate", "1.2.0").returncode, 0) + self.assertNotEqual(run_script("validate", "1.2").returncode, 0) + + def test_validate_refuses_a_trailing_newline(self) -> None: + result = run_script("validate", "1.1.0\n", "--branch", "release/1.1") + self.assertNotEqual(result.returncode, 0) + + def test_release_branch_prints_the_line(self) -> None: + result = run_script("release-branch", "1.1.0-rc1") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "release/1.1") + + def test_release_branch_refuses_a_malformed_version(self) -> None: + self.assertNotEqual(run_script("release-branch", "1.1").returncode, 0) + self.assertNotEqual(run_script("release-branch").returncode, 0) + + def test_branch_with_tag_is_refused(self) -> None: + result = run_script("validate", "--tag", "v1.1.0", "--branch", "release/1.1") + self.assertNotEqual(result.returncode, 0) + self.assertIn("--branch", result.stderr) + + def test_validate_tag_prints_the_bare_version(self) -> None: + result = run_script("validate", "--tag", "v1.1.0-rc1") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "1.1.0-rc1") + + +class ReadWriteTests(unittest.TestCase): + """``read`` / ``write`` against a CMakeLists.txt.""" + + def setUp(self) -> None: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.cmakelists = Path(directory.name) / "CMakeLists.txt" + self.cmakelists.write_text(CMAKELISTS) + + def test_read_composes_the_version(self) -> None: + result = run_script("read", "--cmakelists", str(self.cmakelists)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "1.0.0") + + def test_write_round_trips_and_keeps_the_layout(self) -> None: + result = run_script("write", "1.1.0-rc1", "--cmakelists", str(self.cmakelists)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "1.1.0-rc1") + text = self.cmakelists.read_text() + self.assertIn("set(VERSION_MINOR 1)", text) + self.assertIn("set(VERSION_SUFFIX rc1)", text) + self.assertEqual(text.count("\n"), CMAKELISTS.count("\n")) + + def test_write_stable_writes_an_empty_suffix(self) -> None: + run_script("write", "1.1.0-rc1", "--cmakelists", str(self.cmakelists)) + result = run_script("write", "1.1.0", "--cmakelists", str(self.cmakelists)) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('set(VERSION_SUFFIX "")', self.cmakelists.read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/build-macos.yaml b/.github/workflows/build-macos.yaml index 88fff82b9..cb13c3dbd 100644 --- a/.github/workflows/build-macos.yaml +++ b/.github/workflows/build-macos.yaml @@ -5,6 +5,8 @@ on: branches: - master - "release/*" + # Version-bump PR branches: their PR runs the checks. + - "!release/prep-*" # Tag runs produce the macOS release asset. NOTE: a tag created by a # workflow (GITHUB_TOKEN) fires no push event, so tag-release.yaml also # dispatches this workflow explicitly on refs/tags/; the @@ -15,6 +17,7 @@ on: branches: - master - develop + - "release/*" workflow_dispatch: permissions: diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3ee9bf954..306b010e2 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,6 +5,8 @@ on: branches: - master - "release/*" + # Version-bump PR branches: their PR runs the checks. + - "!release/prep-*" # Tag runs produce the Linux release package set. NOTE: a tag created by a # workflow (GITHUB_TOKEN) fires no push event, so tag-release.yaml also # dispatches this workflow explicitly on the tag ref; the workflow_dispatch @@ -12,9 +14,10 @@ on: tags: - "v*" pull_request: - branches: + branches: - master - develop + - "release/*" workflow_dispatch: permissions: @@ -237,6 +240,16 @@ jobs: if-no-files-found: error retention-days: 30 compression-level: 0 + release-tooling: + name: Release tooling tests + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - name: Test the release version parser + run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v + verify-packages: name: Verify packages needs: [build] @@ -267,21 +280,24 @@ jobs: # pointed at the individual jobs could go green on a run that never built. # The verdict is therefore made explicitly below. Mirrors # wire-sysio/.github/workflows/linux_amd64_build.yaml's all-passing. - needs: [discover, build-platforms, build, verify-packages] + needs: [discover, build-platforms, build, verify-packages, release-tooling] if: always() runs-on: ubuntu-latest steps: - # build and verify-packages are the only two that must SUCCEED: - # build-platforms is legitimately skipped, and a failed discover already - # shows up as a non-success `build`. - - if: needs.build.result != 'success' || needs.verify-packages.result != 'success' + # build, verify-packages and release-tooling must SUCCEED: build-platforms + # is legitimately skipped, and a failed discover already shows up as a + # non-success `build`. + - if: >- + needs.build.result != 'success' || + needs.verify-packages.result != 'success' || + needs.release-tooling.result != 'success' run: false notification: name: Send Notification # verify-packages and all-passing are real gates on this workflow's outcome, # so the notification must both wait for them and report them. - needs: [discover, build-platforms, build, verify-packages, all-passing] + needs: [discover, build-platforms, build, verify-packages, release-tooling, all-passing] if: always() runs-on: ubuntu-latest steps: @@ -300,5 +316,6 @@ jobs: build-platforms:${{ needs.build-platforms.result == 'skipped' && 'success' || needs.build-platforms.result }} build:${{ needs.build.result }} verify-packages:${{ needs.verify-packages.result }} + release-tooling:${{ needs.release-tooling.result }} all-passing:${{ needs.all-passing.result }} github-context: ${{ toJSON(github) }} diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index 81656c946..40a66568a 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -2,10 +2,11 @@ name: Prepare Release # Strategy C, step 1 of 2 (see docs/release-workflow.md). # -# Opens the version-bump PR for the release. `version` is the SOLE input: its -# suffix IS the channel (`1.0.0-dev` / `1.1.0-rc1` => pre-release, `1.0.0` => -# stable), and every downstream step derives the channel from the same place -- -# the VERSION_* lines this workflow writes into CMakeLists.txt. +# Opens the version-bump PR for the release. `version`'s suffix IS the channel +# (`1.0.0-dev` / `1.1.0-rc1` => pre-release, `1.0.0` => stable), and every +# downstream step derives the channel from the same place -- the VERSION_* lines +# this workflow writes into CMakeLists.txt. `branch` is the line the bump lands +# on: master, or the version's release/. branch. # # Post-bump after a stable release is the SAME operation: dispatch this # workflow again with the next development version (e.g. `1.1.0-dev`). @@ -16,6 +17,11 @@ on: description: "Version to prepare, without the leading v (e.g. 1.0.0-dev, 1.1.0-rc1, 1.1.0)" type: string required: true + branch: + description: "Branch to prepare it on: master, or the version's release/. branch (e.g. release/1.1 for 1.1.0-rc1)" + type: string + required: true + default: master defaults: run: @@ -34,15 +40,19 @@ jobs: pull-requests: write env: GH_TOKEN: ${{ github.token }} + # Untrusted dispatch inputs: environment variables only, never interpolated + # into a script body. VERSION: ${{ inputs.version }} + TARGET_BRANCH: ${{ inputs.branch }} steps: + # master stays at the workspace root: its parser vets the inputs and edits + # the target branch's CMakeLists.txt, so no code from the target branch runs + # here. - uses: actions/checkout@v5 with: ref: master - fetch-depth: 0 - - name: Validate version and edit CMakeLists.txt - id: bump + - name: Validate the version and the target branch run: | set -euo pipefail @@ -51,6 +61,36 @@ jobs: # from accepting a version the writer below would then reject. python3 .github/scripts/cmake-version.py validate "$VERSION" \ || { echo "::error::Version '$VERSION' must be semver X.Y.Z with an optional -suffix (e.g. 1.0.0-dev)."; exit 1; } + python3 .github/scripts/cmake-version.py validate "$VERSION" --branch "$TARGET_BRANCH" \ + || { echo "::error::${VERSION} is prepared on master or its own release/. branch, not '${TARGET_BRANCH}'."; exit 1; } + + # Once a line has its release branch, its versions are prepared there. + if [[ "$TARGET_BRANCH" == master ]]; then + release_branch="$(python3 .github/scripts/cmake-version.py release-branch "$VERSION")" + status=0 + git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" >/dev/null || status=$? + if [[ $status -eq 0 ]]; then + echo "::error::${release_branch} exists, so ${VERSION} is prepared there, not on master." + exit 1 + elif [[ $status -ne 2 ]]; then + echo "::error::Could not check origin for ${release_branch} (git ls-remote exit ${status})." + exit 1 + fi + fi + + # refs/heads/ so a tag that shares the branch's name is never checked out. + - name: Check out the target branch + uses: actions/checkout@v5 + with: + ref: refs/heads/${{ inputs.branch }} + path: target + fetch-depth: 0 + + - name: Edit CMakeLists.txt + id: bump + run: | + set -euo pipefail + channel="stable" [[ "$VERSION" == *-* ]] && channel="prerelease" @@ -60,15 +100,15 @@ jobs: # release.yaml's tag cross-check, so those can never drift apart. # `write` prints the version it reads back, so a silent no-op edit # (the failure mode of the inline sed this replaced) fails here. - previous="$(python3 .github/scripts/cmake-version.py read)" - written="$(python3 .github/scripts/cmake-version.py write "$VERSION")" + previous="$(python3 .github/scripts/cmake-version.py read --cmakelists target/CMakeLists.txt)" + written="$(python3 .github/scripts/cmake-version.py write "$VERSION" --cmakelists target/CMakeLists.txt)" if [[ "$written" != "$VERSION" ]]; then echo "::error::CMakeLists.txt reads back as '$written' after the edit, expected '$VERSION'." exit 1 fi echo "--- CMakeLists.txt version block ---" - grep -nE '^set\(VERSION_(MAJOR|MINOR|PATCH|SUFFIX)' CMakeLists.txt + grep -nE '^set\(VERSION_(MAJOR|MINOR|PATCH|SUFFIX)' target/CMakeLists.txt { echo "channel=$channel" @@ -76,15 +116,28 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Push branch and open PR + working-directory: target env: CHANNEL: ${{ steps.bump.outputs.channel }} run: | branch="release/prep-v${VERSION}" + + # A prep branch with an open PR into another branch belongs to that + # preparation: force-pushing it would re-point that PR at this + # branch's history. + open_bases="$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$branch" --state open \ + --json baseRefName --jq '.[].baseRefName')" + other_bases="$(grep -vxF -- "$TARGET_BRANCH" <<<"$open_bases" || true)" + if [[ -n "$other_bases" ]]; then + echo "::error::${branch} has an open PR into ${other_bases//$'\n'/, }. Close it before preparing ${VERSION} on ${TARGET_BRANCH}." + exit 1 + fi + git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -b "$branch" if git diff --quiet; then - echo "::error::CMakeLists.txt already reads $VERSION on master; nothing to prepare." + echo "::error::CMakeLists.txt already reads $VERSION on $TARGET_BRANCH; nothing to prepare." exit 1 fi git add CMakeLists.txt @@ -94,18 +147,18 @@ jobs: # printf, not a heredoc: heredoc bodies would have to start at column # 0, which terminates the YAML block scalar this step lives in. body="$(printf '%s\n' \ - "Prepares the \`v${VERSION}\` release (channel: **${CHANNEL}**)." \ + "Prepares the \`v${VERSION}\` release on \`${TARGET_BRANCH}\` (channel: **${CHANNEL}**)." \ "" \ "- \`CMakeLists.txt\` VERSION_MAJOR/MINOR/PATCH/SUFFIX set to \`${VERSION}\`." \ "- Every package / artifact / asset name derives from those lines; \`release.yaml\` fails fast on any tag-vs-version mismatch." \ "" \ - "After merging, dispatch **Tag Release** with version \`${VERSION}\`.")" + "After merging, dispatch **Tag Release** with version \`${VERSION}\` and branch \`${TARGET_BRANCH}\`.")" # NOTE: a PR opened with GITHUB_TOKEN starts its checks in the # approval-required state -- the human gate includes an # "Approve and run" click before the packaging checks report. gh pr create \ --repo "$GITHUB_REPOSITORY" \ - --base master \ + --base "$TARGET_BRANCH" \ --head "$branch" \ --title "chore(release): prepare v${VERSION}" \ --body "$body" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index dc5f57a0d..2886b51c9 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -20,7 +20,7 @@ on: type: string required: true skip-master-check: - description: "DEBUG ONLY: allow a tag whose commit is not on master" + description: "DEBUG ONLY: allow a tag whose commit is on neither master nor its release/. branch" type: boolean default: false @@ -83,14 +83,35 @@ jobs: fi target_sha="$(git rev-parse "HEAD^{commit}")" - git fetch --force origin master:refs/remotes/origin/master - if ! git merge-base --is-ancestor "$target_sha" origin/master; then + + # The tag must sit on its version's line: master, or the + # release/. branch tag-release.yaml cut it from. The + # branch name is derived here rather than through cmake-version.py + # because this job runs the TAG's copy of the parser, and tags cut + # before release branches existed carry a copy without that rule. + IFS=. read -r major minor _ <<< "$tag_version" + release_branch="release/${major}.${minor}" + lines=(master) + if git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" >/dev/null; then + lines+=("$release_branch") + fi + on_line="" + for line in "${lines[@]}"; do + git fetch --force origin "refs/heads/${line}:refs/remotes/origin/${line}" + if git merge-base --is-ancestor "$target_sha" "origin/${line}"; then + on_line="$line" + break + fi + done + if [[ -z "$on_line" ]]; then if [[ "$SKIP_MASTER_CHECK" == "true" ]]; then - echo "::warning::Tag '$tag' ($target_sha) is not on master; continuing (skip-master-check)." + echo "::warning::Tag '$tag' ($target_sha) is on neither master nor ${release_branch}; continuing (skip-master-check)." else - echo "::error::Tag '$tag' points to $target_sha, which is not on origin/master." + echo "::error::Tag '$tag' points to $target_sha, which is on neither origin/master nor origin/${release_branch}." exit 1 fi + else + echo "Tag '$tag' ($target_sha) is on origin/${on_line}." fi # ONE parser for the VERSION_* block, shared with prepare-release.yaml @@ -219,11 +240,27 @@ jobs: ) cat "dist/wire-cdt-${VERSION}-checksums.txt" - # Stable releases (no version suffix) are explicitly marked `latest` - # rather than relying on the API's make_latest default; prereleases - # can never hold the latest label, so no flag is passed for them. + # The `latest` label belongs to the highest published stable version. A + # patch cut from an older release/. branch after a newer + # line shipped must not take it. The flag is explicit either way, + # because publishing a draft without it lets GitHub default + # make_latest to true. Prereleases can never hold the label, so no flag + # is passed for them. Kept in lock-step with + # wire-sysio/.github/workflows/release.yaml. latest_flag="" - [[ "$TAG" != *-* ]] && latest_flag="--latest" + highest="" + if [[ "$TAG" != *-* ]]; then + published="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases \ + --limit 1000 --json tagName --jq '.[].tagName')" + # Only vX.Y.Z tags take part; TAG itself is one, so the grep always matches. + highest="$(printf '%s\n%s\n' "$published" "$TAG" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n1)" + if [[ "$highest" == "$TAG" ]]; then + latest_flag="--latest" + else + latest_flag="--latest=false" + echo "${highest} outranks ${TAG} and keeps the latest label." + fi + fi if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then # Strategy C: tag-release.yaml already created the DRAFT release @@ -242,3 +279,7 @@ jobs: --generate-notes \ --verify-tag $prerelease_flag $latest_flag fi + # A release published by hand may have taken the label; hand it back. + if [[ "$latest_flag" == "--latest=false" ]]; then + gh release edit "$highest" --repo "$GITHUB_REPOSITORY" --latest + fi diff --git a/.github/workflows/tag-release.yaml b/.github/workflows/tag-release.yaml index fdb837632..3dff31762 100644 --- a/.github/workflows/tag-release.yaml +++ b/.github/workflows/tag-release.yaml @@ -2,10 +2,13 @@ name: "Tag Release" # Gate 2 of the Strategy-C release path (docs/release-workflow.md). The job runs # under the `release` Environment, so it PAUSES for a required reviewer before it -# creates anything. After approval it tags master, opens a DRAFT release, and -# dispatches the platform builds plus the asset workflow. +# creates anything. After approval it tags the HEAD of `branch`, opens a DRAFT +# release, and dispatches the platform builds plus the asset workflow. # -# `version` is the SOLE input; its suffix IS the channel (suffix => prerelease). +# `version`'s suffix IS the channel (suffix => prerelease). `branch` is master or +# the version's release/. branch. The `release` Environment only +# deploys from master, so this workflow is always dispatched on master and takes +# the branch to tag as an input. # # Structurally identical to wire-sysio/.github/workflows/tag-release.yaml -- the # two repos run the same release path, so the two files are kept in lock-step. @@ -13,9 +16,14 @@ on: workflow_dispatch: inputs: version: - description: "Version to tag, without a leading v (e.g. 1.0.0-dev, 1.1.0-rc1, 1.1.0). Must already be master's version." + description: "Version to tag, without a leading v (e.g. 1.0.0-dev, 1.1.0-rc1, 1.1.0). Must already be the branch's version." type: string required: true + branch: + description: "Branch whose HEAD is tagged: master, or the version's release/. branch (e.g. release/1.1 for 1.1.0-rc1)" + type: string + required: true + default: master permissions: contents: write @@ -35,37 +43,78 @@ defaults: jobs: tag: - name: Tag master and start the release builds + name: Tag the branch and start the release builds runs-on: ubuntu-latest # Required reviewers on this Environment are gate 2 -- nothing below runs until # a human approves the deployment. environment: release env: GH_TOKEN: ${{ github.token }} - # Untrusted dispatch input: environment variable only, never interpolated + # Untrusted dispatch inputs: environment variables only, never interpolated # into a script body. VERSION: ${{ inputs.version }} + TARGET_BRANCH: ${{ inputs.branch }} steps: + # master stays at the workspace root: its parser vets the inputs and reads + # the target branch's version, so no code from the target branch runs here. - uses: actions/checkout@v5 with: ref: master - - name: Assert master HEAD carries the requested version - id: assert + - name: Validate the version and the target branch run: | set -euo pipefail # The grammar is cmake-version.py's VERSION_RE, asserted by the parser - # itself rather than re-spelled here -- the same check prepare-release - # ran on this version before it reached master. + # itself rather than re-spelled here -- the same checks prepare-release + # ran on this version and branch before the bump merged. python3 .github/scripts/cmake-version.py validate "$VERSION" \ || { echo "::error::'${VERSION}' is not ..[-] (no leading v)" >&2; exit 1; } + python3 .github/scripts/cmake-version.py validate "$VERSION" --branch "$TARGET_BRANCH" \ + || { echo "::error::${VERSION} is tagged on master or its own release/. branch, not '${TARGET_BRANCH}'" >&2; exit 1; } + + # Once a line has its release branch, its versions are tagged there. + if [[ "$TARGET_BRANCH" == master ]]; then + release_branch="$(python3 .github/scripts/cmake-version.py release-branch "$VERSION")" + status=0 + git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" >/dev/null || status=$? + if [[ $status -eq 0 ]]; then + echo "::error::${release_branch} exists, so ${VERSION} is tagged there, not on master." >&2 + exit 1 + elif [[ $status -ne 2 ]]; then + echo "::error::Could not check origin for ${release_branch} (git ls-remote exit ${status})." >&2 + exit 1 + fi + fi + + # The bump PR (gate 1) shows only the version change, so the branch's + # other commits must already have been reviewed: refuse a branch whose + # rules do not require a pull request. + rules="$(gh api "repos/${GITHUB_REPOSITORY}/rules/branches/${TARGET_BRANCH}" --jq '[.[].type] | join(",")')" + if [[ ",${rules}," != *",pull_request,"* ]]; then + echo "::error::${TARGET_BRANCH} has no pull_request rule (rules: ${rules:-none}). Protect it before tagging from it." >&2 + exit 1 + fi + + # refs/heads/ so a tag that shares the branch's name is never checked out. + # No credentials: nothing in this job pushes from the target tree. + - name: Check out the target branch + uses: actions/checkout@v5 + with: + ref: refs/heads/${{ inputs.branch }} + path: target + persist-credentials: false + + - name: Assert the branch HEAD carries the requested version + id: assert + run: | + set -euo pipefail # The same parser prepare-release.yaml writes with and release.yaml # cross-checks the tag against. - head_version="$(python3 .github/scripts/cmake-version.py read)" + head_version="$(python3 .github/scripts/cmake-version.py read --cmakelists target/CMakeLists.txt)" if [[ "$head_version" != "$VERSION" ]]; then - echo "::error::master HEAD is version ${head_version}, not ${VERSION}. Merge the prepare-release PR first." >&2 + echo "::error::${TARGET_BRANCH} HEAD is version ${head_version}, not ${VERSION}. Merge the prepare-release PR first." >&2 exit 1 fi @@ -84,7 +133,7 @@ jobs: { echo "tag=$tag" - echo "sha=$(git rev-parse HEAD)" + echo "sha=$(git -C target rev-parse HEAD)" echo "prerelease=$prerelease" } >> "$GITHUB_OUTPUT" diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 671e3589a..7ffe68d4c 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -81,28 +81,67 @@ for, a wire-cdt release. Consumers pin a published release asset. Two human gates, two dispatches: 1. **Dispatch `Prepare Release`** with the version (its suffix picks the - channel). It rewrites the four `VERSION_*` lines on a `release/prep-v` - branch and opens the bump PR. + channel) and the branch it lands on (`master`, or the version's + `release/.` branch). It rewrites the four `VERSION_*` lines on a + `release/prep-v` branch and opens the bump PR against that branch. 2. **GATE 1 — review + merge the PR.** Its checks start in the approval-required state (a GITHUB_TOKEN-opened PR), so the gate includes an "Approve and run" click. -3. **Dispatch `Tag Release`** with the same version. +3. **Dispatch `Tag Release`** with the same version and branch. 4. **GATE 2 — approve the `release` Environment.** `Tag Release`'s job is gated on it; nothing is written before the approval. -5. The system then: asserts master HEAD's `VERSION_*` equals the version, +5. The system then: asserts the branch HEAD's `VERSION_*` equals the version, creates the annotated tag (two API calls — the tag object, then the ref) and a **draft** release with generated notes, and **dispatches the linux + macOS builds on `refs/tags/`** — a GITHUB_TOKEN-created tag fires no push event, so the tag-ref builds must be dispatched explicitly. Finally it dispatches `Publish Release` with the tag. -6. `Publish Release` waits for both tag-ref builds, downloads both package - artifacts, verifies each artifact explicitly, attaches them plus the - checksums file to the draft, and flips the draft public. (A GITHUB_TOKEN - draft flip does not re-fire `release: published` — no recursion, by design.) +6. `Publish Release` checks the tag sits on master or its version's + `release/.` branch, waits for both tag-ref builds, downloads + both package artifacts, verifies each artifact explicitly, attaches them plus + the checksums file to the draft, and flips the draft public. The `latest` + label goes to the highest published stable version. (A GITHUB_TOKEN draft + flip does not re-fire `release: published` — no recursion, by design.) **Post-bump** after a stable release is the same operation as step 1: dispatch `Prepare Release` again with the next development version (e.g. `1.1.0-dev`). +### Release branches + +A `release/.` branch carries one line's releases (`1.1.0-rc1`, +`1.1.0`, `1.1.1`, …) while master moves on. The same workflows prepare, tag and +publish on it. + +- **Cutting one.** Create `release/.` from the master commit the + line starts at. Its first bump (say `1.1.0-rc1`) goes through + `Prepare Release` with branch `release/1.1`. Master can then move to the next + line's `-dev` version the same way, with branch `master`. +- **Preparing and tagging.** Dispatch both workflows on master. `Tag Release` + has to run on master, because the `release` Environment only deploys from + master. Both keep master's checkout at the workspace root and check the target + branch out into `target/` (always as `refs/heads/`, never a tag of the + same name). Master's `cmake-version.py` validates the inputs and reads and + writes the target's version, so no code from the target branch runs in either + job. +- **Which versions go where.** A release branch takes only its own line's + versions (`release/1.1` refuses `1.2.0`). Master takes any version whose line + has no release branch yet: once `release/1.1` exists, `1.1.*` is refused on + master. +- **Protection.** `Tag Release` refuses a branch whose rules do not require a + pull request: the bump PR shows only the version change, so every other commit + on the branch must already have been reviewed. `release/*` gets the same + branch ruleset as master (approved PRs, no force-push, no deletion), with + `release/prep-*` excluded because `Prepare Release` pushes, and on a re-run + force-pushes, its bump branches there. +- **CI.** Pushes to a release branch run the linux and macOS builds, and PRs into + one run the same checks as PRs into master. Pushes to `release/prep-*` build + nothing; their PRs run the checks. +- **The `latest` label.** It goes to the highest published stable version, so a + `1.1.1` cut after `1.2.0` leaves `1.2.0` as latest. +- **Release notes.** `generate-notes` picks the previous release itself, so the + notes of a patch cut from an older release branch can start from a newer + line's release. + ### Failure and rollback - `Publish Release` is idempotent for a tag (`upload --clobber`, draft @@ -122,12 +161,12 @@ flowchart TD classDef gate fill:#FFD9D9,stroke:#a83232,color:#1a1a1a H1[/"Human: pick version — its suffix IS the channel
(-dev/-rcN = prerelease, none = stable)"/]:::human - H2[/"Human: dispatch prepare-release (input: version)"/]:::human + H2[/"Human: dispatch prepare-release (inputs: version, branch)"/]:::human S1["System: edit VERSION_*, resolve+pin latest
wire-cdt release (sysio only), open bump PR"]:::system G1{{"GATE 1 — Human: 'Approve and run' the PR checks,
review (incl. pinned CDT), merge"}}:::gate - H3[/"Human: dispatch tag-release (input: version)"/]:::human + H3[/"Human: dispatch tag-release (inputs: version, branch)"/]:::human G2{{"GATE 2 — Human: approve 'release' Environment"}}:::gate - S3["System: assert version == master HEAD,
create annotated tag + DRAFT release,
DISPATCH linux + macos builds on the tag ref"]:::system + S3["System: assert version == branch HEAD,
create annotated tag + DRAFT release,
DISPATCH linux + macos builds on the tag ref"]:::system S4["System: tag builds produce packages
(sysio: installs pinned wire-cdt release deb,
builds + asserts system contracts, bundles them)"]:::system S6["System: release.yaml — await tag-ref builds,
download, verify each artifact explicitly"]:::system S7["System: attach assets + checksums,
flip draft to public"]:::system @@ -204,7 +243,7 @@ with `wire-cdt`'s own `Provides: cdt`, blocking their co-install. ```mermaid flowchart TD - A[Prepare Release: bump VERSION_* on release/prep-v*] --> B[Merge to master] + A[Prepare Release: bump VERSION_* on release/prep-v*] --> B[Merge to master or release/X.Y] B --> C[Tag Release: annotated tag + draft release] C --> D[Dispatched builds on refs/tags/vX.Y.Z] D --> D1[Build toolchain + tests
linux amd64 + macOS arm64] @@ -213,7 +252,7 @@ flowchart TD D3 --> E{verify-packages:
payload + container install +
contract-compile smoke} E -- fail --> X1[Fix, re-dispatch — never re-tag] E -- pass --> F[Publish Release workflow] - F --> F1{Tag valid? on master?
matches VERSION_* incl. suffix?} + F --> F1{Tag valid? on master or release/X.Y?
matches VERSION_* incl. suffix?} F1 -- no --> X2[Fail fast] F1 -- yes --> G[Resolve both tag-ref build runs] G --> H[Download + re-verify each artifact] From 59ec0031c3e79d4e541907507be1acca7440f95d Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 5 Nov 2026 12:25:07 -0600 Subject: [PATCH 2/4] ci: fail a PR that changes its base branch's version outside a bump PR A forward merge from a release branch can carry that branch's version into the target without a conflict when each side changed different VERSION_* lines: master at 1.2.0-dev merged with release/1.1 going 1.1.0-dev to 1.1.0 becomes 1.2.0. cmake-version.py check-pr compares a PR's merge result with its base and passes a changed version only from that version's release/prep-v branch, onto a branch that may carry it. release-tooling runs it on every PR, and Prepare Release takes the prep branch name from the parser's new prep-branch mode. read and write refuse more than one set(VERSION_*) line, so a tree with unresolved conflict markers fails instead of reporting one side's version. docs/release-workflow.md documents forwarding a release branch's PRs to master, and cutting a release branch once master holds what the line ships. --- .github/scripts/cmake-version.py | 101 +++++++++++++++++++-- .github/scripts/test_cmake_version.py | 118 +++++++++++++++++++++++++ .github/workflows/build.yaml | 19 ++++ .github/workflows/prepare-release.yaml | 7 +- docs/release-workflow.md | 30 ++++++- 5 files changed, 267 insertions(+), 8 deletions(-) diff --git a/.github/scripts/cmake-version.py b/.github/scripts/cmake-version.py index afa3c2ebc..8e21fe5ca 100755 --- a/.github/scripts/cmake-version.py +++ b/.github/scripts/cmake-version.py @@ -31,6 +31,11 @@ ``release-branch`` prints the branch of a version's line, so those workflows can also refuse master for a line whose release branch already exists. +``check-pr`` is the pull-request check: only the bump PR prepare-release.yaml +opens from ``prep-branch``'s branch changes the base branch's version. Every +other PR, a forward merge from an older release branch included, leaves it as +it is. + Usage:: cmake-version.py read [--cmakelists PATH] @@ -38,6 +43,9 @@ cmake-version.py validate [--branch BRANCH] cmake-version.py validate --tag cmake-version.py release-branch + cmake-version.py prep-branch + cmake-version.py check-pr --base-branch BRANCH --head-branch BRANCH + --base-cmakelists PATH [--cmakelists PATH] """ from __future__ import annotations @@ -60,6 +68,10 @@ #: Prefix of a release branch, ``release/.``. RELEASE_BRANCH_PREFIX = "release/" +#: Prefix of the branch prepare-release.yaml opens a bump PR from, +#: ``release/prep-v``. +PREP_BRANCH_PREFIX = f"{RELEASE_BRANCH_PREFIX}prep-v" + def _field_re(name: str) -> re.Pattern[str]: """Build the anchored matcher for one ``set( )`` line. @@ -73,6 +85,21 @@ def _field_re(name: str) -> re.Pattern[str]: return re.compile(rf"^set\({name}\s+(.*)\)[ \t]*$", re.MULTILINE) +def _single_field(text: str, name: str) -> re.Match[str] | None: + """Return the one ``set( ...)`` line in ``text``, or ``None`` if there is none. + + More than one is refused: a merge that left conflict markers in the version + block carries both sides' lines, and reading only the first would report one + side's version as the tree's. + """ + matches = list(_field_re(name).finditer(text)) + if len(matches) > 1: + raise SystemExit( + f"cmake-version: found {len(matches)} set({name} ...) lines, expected one" + ) + return matches[0] if matches else None + + def validate_version(version: str, tag: bool = False) -> str: """Return the BARE version after asserting ``version`` matches the grammar. @@ -124,11 +151,40 @@ def validate_branch(branch: str, version: str) -> None: ) +def prep_branch(version: str) -> str: + """Return the ``release/prep-v`` branch of ``version``'s bump PR.""" + return f"{PREP_BRANCH_PREFIX}{version}" + + +def check_pr_version( + base_branch: str, head_branch: str, base_version: str, merged_version: str +) -> None: + """Assert a PR from ``head_branch`` into ``base_branch`` changes the version only if it is a bump PR. + + ``base_version`` is the base branch's version and ``merged_version`` the PR's + merge result's. A changed version passes only from the merged version's + :func:`prep_branch`, and only onto a branch :func:`validate_branch` lets carry + it. A forward merge is refused when it carries the source branch's version, + which git merges without a conflict when the two sides changed different + ``VERSION_*`` lines. + """ + if merged_version == base_version: + return + if head_branch == prep_branch(merged_version): + validate_branch(base_branch, validate_version(merged_version)) + return + raise SystemExit( + f"cmake-version: this PR changes {base_branch}'s version from {base_version} " + f"to {merged_version}. Only a prepare-release bump PR changes it; keep " + f"{base_branch}'s set(VERSION_*) lines." + ) + + def read_version(text: str) -> str: """Return the ``VERSION_FULL`` string composed from the ``set(VERSION_*)`` lines.""" parts = {} for name in ("VERSION_MAJOR", "VERSION_MINOR", "VERSION_PATCH", "VERSION_SUFFIX"): - match = _field_re(name).search(text) + match = _single_field(text, name) if match is None: if name == "VERSION_SUFFIX": parts[name] = "" @@ -160,16 +216,19 @@ def write_version(text: str, version: str) -> str: "VERSION_SUFFIX": suffix if suffix else '""', } for name, value in replacements.items(): - text, count = _field_re(name).subn(f"set({name} {value})", text, count=1) - if count != 1: + if _single_field(text, name) is None: raise SystemExit(f"cmake-version: could not rewrite set({name} ...)") + text = _field_re(name).sub(f"set({name} {value})", text, count=1) return text def main() -> int: - """Parse arguments and dispatch to the read or write mode.""" + """Parse arguments and dispatch to the requested mode.""" parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("mode", choices=("read", "write", "validate", "release-branch")) + parser.add_argument( + "mode", + choices=("read", "write", "validate", "release-branch", "prep-branch", "check-pr"), + ) parser.add_argument( "version", nargs="?", help="version to write or validate" ) @@ -187,6 +246,18 @@ def main() -> int: metavar="BRANCH", help="validate mode: also assert the version may be prepared and tagged on BRANCH", ) + parser.add_argument( + "--base-branch", metavar="BRANCH", help="check-pr mode: the branch the PR merges into" + ) + parser.add_argument( + "--head-branch", metavar="BRANCH", help="check-pr mode: the branch the PR merges from" + ) + parser.add_argument( + "--base-cmakelists", + type=Path, + metavar="PATH", + help="check-pr mode: the base branch's CMakeLists.txt (--cmakelists is the merge result's)", + ) parser.add_argument("--cmakelists", type=Path, default=DEFAULT_CMAKELISTS) args = parser.parse_args() @@ -217,8 +288,28 @@ def main() -> int: print(release_branch(validate_version(args.version))) return 0 + if args.mode == "prep-branch": + if not args.version: + raise SystemExit("cmake-version: prep-branch mode requires a version argument") + print(prep_branch(validate_version(args.version))) + return 0 + text = args.cmakelists.read_text() + if args.mode == "check-pr": + if None in (args.base_branch, args.head_branch, args.base_cmakelists): + raise SystemExit( + "cmake-version: check-pr mode requires --base-branch, --head-branch " + "and --base-cmakelists" + ) + check_pr_version( + args.base_branch, + args.head_branch, + read_version(args.base_cmakelists.read_text()), + read_version(text), + ) + return 0 + if args.mode == "read": print(read_version(text)) return 0 diff --git a/.github/scripts/test_cmake_version.py b/.github/scripts/test_cmake_version.py index e316dc599..472099623 100644 --- a/.github/scripts/test_cmake_version.py +++ b/.github/scripts/test_cmake_version.py @@ -110,6 +110,51 @@ def test_refuses_branches_that_are_neither_master_nor_a_release_line(self) -> No cmake_version.validate_branch(branch, "1.1.0") +class PrepBranchTests(unittest.TestCase): + """``prep_branch`` names a version's bump-PR branch.""" + + def test_names_the_bump_branch(self) -> None: + self.assertEqual(cmake_version.prep_branch("1.1.0-rc1"), "release/prep-v1.1.0-rc1") + + +class CheckPrVersionTests(unittest.TestCase): + """Only a bump PR changes its base branch's version.""" + + FORWARD = "forward/release-1.1-to-master" + + def test_unchanged_version_passes_from_any_branch(self) -> None: + for head in ("feature/fix", self.FORWARD, "release/1.1", "release/prep-v1.2.0-dev"): + with self.subTest(head=head): + cmake_version.check_pr_version("master", head, "1.2.0-dev", "1.2.0-dev") + + def test_forward_merge_carrying_the_source_version_fails(self) -> None: + # 1.2.0 and 1.2.0-rc2 are what git merges silently when each side changed + # different VERSION_* lines; 1.1.0 is a conflict resolved the wrong way. + for merged in ("1.2.0", "1.2.0-rc2", "1.1.0"): + with self.subTest(merged=merged), self.assertRaises(SystemExit): + cmake_version.check_pr_version("master", self.FORWARD, "1.2.0-dev", merged) + + def test_bump_pr_moves_its_base_to_its_version(self) -> None: + for base, base_version, version in ( + ("master", "1.0.0", "1.2.0-dev"), + ("release/1.1", "1.0.0", "1.1.0-rc1"), + ("release/1.1", "1.1.0", "1.1.1-dev"), + ): + with self.subTest(base=base, version=version): + cmake_version.check_pr_version(base, f"release/prep-v{version}", base_version, version) + + def test_bump_pr_refuses_a_branch_that_may_not_carry_its_version(self) -> None: + for base in ("release/1.1", "develop"): + with self.subTest(base=base), self.assertRaises(SystemExit): + cmake_version.check_pr_version(base, "release/prep-v1.2.0", "1.1.0", "1.2.0") + + def test_bump_pr_refuses_a_version_other_than_its_own(self) -> None: + with self.assertRaises(SystemExit): + cmake_version.check_pr_version( + "release/1.1", "release/prep-v1.1.0-rc1", "1.0.0", "1.1.0-rc2" + ) + + class CommandLineTests(unittest.TestCase): """``validate`` as the workflows invoke it.""" @@ -152,6 +197,66 @@ def test_validate_tag_prints_the_bare_version(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.stdout.strip(), "1.1.0-rc1") + def test_prep_branch_prints_the_bump_branch(self) -> None: + result = run_script("prep-branch", "1.1.0-rc1") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "release/prep-v1.1.0-rc1") + + def test_prep_branch_refuses_a_malformed_version(self) -> None: + self.assertNotEqual(run_script("prep-branch", "1.1").returncode, 0) + self.assertNotEqual(run_script("prep-branch").returncode, 0) + + +class CheckPrCommandLineTests(unittest.TestCase): + """``check-pr`` as the PR workflows invoke it, against two CMakeLists.txt files.""" + + def setUp(self) -> None: + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.directory = Path(directory.name) + + def cmakelists(self, name: str, version: str) -> Path: + """Write a CMakeLists.txt carrying ``version`` and return its path.""" + path = self.directory / name + path.write_text(cmake_version.write_version(CMAKELISTS, version)) + return path + + def check_pr(self, head: str, base_version: str, merged_version: str) -> subprocess.CompletedProcess[str]: + """Run ``check-pr`` for a PR from ``head`` into master.""" + return run_script( + "check-pr", + "--base-branch", "master", + "--head-branch", head, + "--base-cmakelists", str(self.cmakelists("base.txt", base_version)), + "--cmakelists", str(self.cmakelists("merged.txt", merged_version)), + ) + + def test_unchanged_version_passes_silently(self) -> None: + result = self.check_pr("feature/fix", "1.2.0-dev", "1.2.0-dev") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + + def test_changed_version_names_both_versions(self) -> None: + result = self.check_pr("forward/release-1.1-to-master", "1.2.0-dev", "1.2.0") + self.assertNotEqual(result.returncode, 0) + self.assertIn("from 1.2.0-dev to 1.2.0", result.stderr) + + def test_bump_pr_passes(self) -> None: + result = self.check_pr("release/prep-v1.2.0", "1.2.0-dev", "1.2.0") + self.assertEqual(result.returncode, 0, result.stderr) + + def test_requires_both_branches_and_the_base_file(self) -> None: + base = str(self.cmakelists("base.txt", "1.0.0")) + for args in ( + ("--head-branch", "x", "--base-cmakelists", base), + ("--base-branch", "master", "--base-cmakelists", base), + ("--base-branch", "master", "--head-branch", "x"), + ): + with self.subTest(args=args): + result = run_script("check-pr", *args, "--cmakelists", base) + self.assertNotEqual(result.returncode, 0) + self.assertIn("--base-cmakelists", result.stderr) + class ReadWriteTests(unittest.TestCase): """``read`` / ``write`` against a CMakeLists.txt.""" @@ -182,6 +287,19 @@ def test_write_stable_writes_an_empty_suffix(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) self.assertIn('set(VERSION_SUFFIX "")', self.cmakelists.read_text()) + def test_read_and_write_refuse_an_unresolved_merge_conflict(self) -> None: + conflicted = CMAKELISTS.replace( + "set(VERSION_MINOR 0)\n", + "<<<<<<< HEAD\nset(VERSION_MINOR 2)\n=======\nset(VERSION_MINOR 1)\n>>>>>>> release/1.1\n", + ) + self.cmakelists.write_text(conflicted) + for args in (("read",), ("write", "1.2.0")): + with self.subTest(mode=args[0]): + result = run_script(*args, "--cmakelists", str(self.cmakelists)) + self.assertNotEqual(result.returncode, 0) + self.assertIn("set(VERSION_MINOR ...)", result.stderr) + self.assertEqual(self.cmakelists.read_text(), conflicted) + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 306b010e2..3bfcae18e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -249,6 +249,25 @@ jobs: - uses: actions/checkout@v5 - name: Test the release version parser run: python3 -m unittest discover -s .github/scripts -p 'test_*.py' -v + # Only a Prepare Release bump PR changes the version. A forward merge from a + # release branch can carry that branch's version without a conflict. + - name: Check that only a bump PR changes the version + if: github.event_name == 'pull_request' + env: + # Untrusted PR fields: environment variables only, never interpolated + # into the script body. + BASE_BRANCH: ${{ github.base_ref }} + HEAD_BRANCH: ${{ github.head_ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + # The checkout is the PR's merge result; the base branch's file is + # fetched to compare against it. + git fetch --no-tags --depth=1 origin "$BASE_SHA" + git show "${BASE_SHA}:CMakeLists.txt" > "$RUNNER_TEMP/base-CMakeLists.txt" + python3 .github/scripts/cmake-version.py check-pr \ + --base-branch "$BASE_BRANCH" --head-branch "$HEAD_BRANCH" \ + --base-cmakelists "$RUNNER_TEMP/base-CMakeLists.txt" verify-packages: name: Verify packages diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index 40a66568a..c7222a9df 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -110,17 +110,22 @@ jobs: echo "--- CMakeLists.txt version block ---" grep -nE '^set\(VERSION_(MAJOR|MINOR|PATCH|SUFFIX)' target/CMakeLists.txt + # The parser names the bump branch, so the PR version check recognizes it. + prep_branch="$(python3 .github/scripts/cmake-version.py prep-branch "$VERSION")" + { echo "channel=$channel" echo "previous=$previous" + echo "prep-branch=$prep_branch" } >> "$GITHUB_OUTPUT" - name: Push branch and open PR working-directory: target env: CHANNEL: ${{ steps.bump.outputs.channel }} + PREP_BRANCH: ${{ steps.bump.outputs.prep-branch }} run: | - branch="release/prep-v${VERSION}" + branch="$PREP_BRANCH" # A prep branch with an open PR into another branch belongs to that # preparation: force-pushing it would re-point that PR at this diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 7ffe68d4c..e54553bf9 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -112,8 +112,9 @@ A `release/.` branch carries one line's releases (`1.1.0-rc1`, `1.1.0`, `1.1.1`, …) while master moves on. The same workflows prepare, tag and publish on it. -- **Cutting one.** Create `release/.` from the master commit the - line starts at. Its first bump (say `1.1.0-rc1`) goes through +- **Cutting one.** Create `release/.` from master once master holds + everything the line ships. From then on a change reaches the line only through + a PR into the branch. Its first bump (say `1.1.0-rc1`) goes through `Prepare Release` with branch `release/1.1`. Master can then move to the next line's `-dev` version the same way, with branch `master`. - **Preparing and tagging.** Dispatch both workflows on master. `Tag Release` @@ -127,6 +128,31 @@ publish on it. versions (`release/1.1` refuses `1.2.0`). Master takes any version whose line has no release branch yet: once `release/1.1` exists, `1.1.*` is refused on master. +- **Forwarding.** Every PR merged into a release branch is merged forward through + each newer line to master: `release/1.1` into `release/1.2`, then + `release/1.2` into master. Nothing is merged the other way, so a release + branch never picks up a newer line's work. + - Branch from the target, outside `release/`, merge the source release branch + without committing, and open a PR into the target: + + ```bash + git switch -c forward/release-1.1-to-master origin/master + git merge --no-ff --no-commit origin/release/1.1 + # Resolve any conflicts, then confirm the version is still master's: + python3 .github/scripts/cmake-version.py read + git commit + ``` + + - The target keeps its own `VERSION_*` lines. A bump on the source does not + always conflict: when the two sides changed different lines, git merges them + silently (master at `1.2.0-dev` plus `release/1.1` going `1.1.0-dev` to + `1.1.0` merges to `1.2.0`). CI fails any PR that changes its base branch's + version, except the `release/prep-v` bump PR for a version that + branch may carry. + - Merge the PR with a merge commit. A squash or rebase leaves the release + branch's commits unmerged, so every later forward merges them again. + - Never use GitHub's "Resolve conflicts" on a PR whose head is the release + branch: it commits the target into the release branch. - **Protection.** `Tag Release` refuses a branch whose rules do not require a pull request: the bump PR shows only the version change, so every other commit on the branch must already have been reviewed. `release/*` gets the same From 17596bd3244b27b7eae306e4c8bb0787059685e8 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 5 Nov 2026 12:35:46 -0600 Subject: [PATCH 3/4] ci: post-bump release branches and add the highest-tag parser mode After a stable release, the branch it shipped from is post-bumped: master to its next -dev version, a release branch to its next patch's (1.1.0 to 1.1.1-dev). The docs and the Prepare Release comments say so. cmake-version.py gains highest-tag, which prints the highest release tag in a version's . line or an older one, ordering versions numerically and skipping tags outside the grammar. wire-sysio's tag build picks its wire-cdt release with it, and the parser stays identical in both repos. --- .github/scripts/cmake-version.py | 64 +++++++++++++++++++++++++- .github/scripts/test_cmake_version.py | 61 +++++++++++++++++++++++- .github/workflows/prepare-release.yaml | 4 +- docs/release-workflow.md | 4 +- 4 files changed, 129 insertions(+), 4 deletions(-) diff --git a/.github/scripts/cmake-version.py b/.github/scripts/cmake-version.py index 8e21fe5ca..5795d5891 100755 --- a/.github/scripts/cmake-version.py +++ b/.github/scripts/cmake-version.py @@ -36,6 +36,10 @@ other PR, a forward merge from an older release branch included, leaves it as it is. +``highest-tag`` reads release tags on stdin and prints the highest one in the +version's ``.`` line or an older one. wire-sysio's tag build picks +its wire-cdt release with it, so a patch on an older line keeps that line's CDT. + Usage:: cmake-version.py read [--cmakelists PATH] @@ -46,6 +50,7 @@ cmake-version.py prep-branch cmake-version.py check-pr --base-branch BRANCH --head-branch BRANCH --base-cmakelists PATH [--cmakelists PATH] + cmake-version.py highest-tag < tags """ from __future__ import annotations @@ -53,6 +58,7 @@ import argparse import re import sys +from collections.abc import Iterable from pathlib import Path #: ``..`` with an optional ``-`` of dot-separated @@ -72,6 +78,10 @@ #: ``release/prep-v``. PREP_BRANCH_PREFIX = f"{RELEASE_BRANCH_PREFIX}prep-v" +#: Splits a version suffix into its digit and non-digit runs, so ``rc10`` sorts +#: after ``rc2``. +SUFFIX_RUN_RE = re.compile(r"(\d+)") + def _field_re(name: str) -> re.Pattern[str]: """Build the anchored matcher for one ``set( )`` line. @@ -125,6 +135,41 @@ def validate_version(version: str, tag: bool = False) -> str: return bare +def version_key(version: str) -> tuple[object, ...]: + """Return the key that orders versions the way they ship. + + ``version`` must already have passed :func:`validate_version`. Major, minor + and patch compare as numbers, a prerelease sorts before the stable version it + leads to, and suffixes compare run by run with numbers compared numerically. + """ + major, minor, patch, suffix = VERSION_RE.match(version).groups() + runs = tuple( + (0, int(run)) if run.isdigit() else (1, run) + for run in SUFFIX_RUN_RE.split(suffix or "") + if run + ) + return (int(major), int(minor), int(patch), 0 if suffix else 1, runs) + + +def highest_tag(tags: Iterable[str], version: str) -> str | None: + """Return the highest tag in ``tags`` whose ``.`` line is ``version``'s or older. + + ``version`` must already have passed :func:`validate_version`. Tags outside + the ``v..[-]`` grammar are skipped. Returns + ``None`` when no tag qualifies. + """ + line = version_key(version)[:2] + best: tuple[tuple[object, ...], str] | None = None + for tag in tags: + try: + key = version_key(validate_version(tag, tag=True)) + except SystemExit: + continue + if key[:2] <= line and (best is None or key > best[0]): + best = (key, tag) + return best[1] if best else None + + def release_branch(version: str) -> str: """Return the ``release/.`` branch of ``version``'s line. @@ -227,7 +272,15 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "mode", - choices=("read", "write", "validate", "release-branch", "prep-branch", "check-pr"), + choices=( + "read", + "write", + "validate", + "release-branch", + "prep-branch", + "check-pr", + "highest-tag", + ), ) parser.add_argument( "version", nargs="?", help="version to write or validate" @@ -294,6 +347,15 @@ def main() -> int: print(prep_branch(validate_version(args.version))) return 0 + # Prints nothing when no tag qualifies, so the caller reports the miss. + if args.mode == "highest-tag": + if not args.version: + raise SystemExit("cmake-version: highest-tag mode requires a version argument") + tag = highest_tag(sys.stdin.read().split(), validate_version(args.version)) + if tag is not None: + print(tag) + return 0 + text = args.cmakelists.read_text() if args.mode == "check-pr": diff --git a/.github/scripts/test_cmake_version.py b/.github/scripts/test_cmake_version.py index 472099623..d1bcf90b3 100644 --- a/.github/scripts/test_cmake_version.py +++ b/.github/scripts/test_cmake_version.py @@ -33,10 +33,11 @@ """ -def run_script(*args: str) -> subprocess.CompletedProcess[str]: +def run_script(*args: str, stdin: str = "") -> subprocess.CompletedProcess[str]: """Run the parser the way the workflows do, capturing its output.""" return subprocess.run( [sys.executable, str(SCRIPT), *args], + input=stdin, capture_output=True, text=True, check=False, @@ -110,6 +111,50 @@ def test_refuses_branches_that_are_neither_master_nor_a_release_line(self) -> No cmake_version.validate_branch(branch, "1.1.0") +class VersionKeyTests(unittest.TestCase): + """``version_key`` orders versions the way they ship.""" + + def test_orders_numerically_with_prereleases_first(self) -> None: + shipped = [ + "0.9.9", "1.0.0-dev", "1.0.0-rc1", "1.0.0-rc2", "1.0.0-rc10", "1.0.0", + "1.0.1", "1.1.0", "1.9.0", "1.10.0", "2.0.0", + ] + self.assertEqual(sorted(reversed(shipped), key=cmake_version.version_key), shipped) + + +class HighestTagTests(unittest.TestCase): + """``highest_tag`` picks within a version's line or an older one.""" + + TAGS = ["v1.0.0", "v1.0.1", "v1.1.0", "v1.1.2", "v1.2.0", "v2.0.0"] + + def test_picks_the_highest_at_or_below_the_line(self) -> None: + for version, expected in ( + ("1.1.5", "v1.1.2"), + ("1.1.0-rc1", "v1.1.2"), + ("1.0.4", "v1.0.1"), + ("1.3.0-dev", "v1.2.0"), + ("3.0.0", "v2.0.0"), + ): + with self.subTest(version=version): + self.assertEqual(cmake_version.highest_tag(self.TAGS, version), expected) + + def test_returns_none_when_every_tag_is_on_a_newer_line(self) -> None: + self.assertIsNone(cmake_version.highest_tag(self.TAGS, "0.9.0")) + self.assertIsNone(cmake_version.highest_tag([], "1.0.0")) + + def test_compares_lines_and_suffixes_numerically(self) -> None: + self.assertEqual(cmake_version.highest_tag(["v1.9.3", "v1.10.0"], "1.9.0"), "v1.9.3") + self.assertEqual(cmake_version.highest_tag(["v1.10.0", "v1.9.3"], "1.10.0"), "v1.10.0") + self.assertEqual( + cmake_version.highest_tag(["v1.1.0-rc2", "v1.1.0-rc10", "v1.1.0-dev"], "1.1.0-rc1"), + "v1.1.0-rc10", + ) + + def test_skips_tags_outside_the_grammar(self) -> None: + tags = ["latest", "v1.1", "1.1.0", "cdt-v1.1.0", "v9.9.9-rc-1", "v1.1.0.1", "", "v1.0.0"] + self.assertEqual(cmake_version.highest_tag(tags, "9.9.9"), "v1.0.0") + + class PrepBranchTests(unittest.TestCase): """``prep_branch`` names a version's bump-PR branch.""" @@ -206,6 +251,20 @@ def test_prep_branch_refuses_a_malformed_version(self) -> None: self.assertNotEqual(run_script("prep-branch", "1.1").returncode, 0) self.assertNotEqual(run_script("prep-branch").returncode, 0) + def test_highest_tag_reads_tags_from_stdin(self) -> None: + result = run_script("highest-tag", "1.1.3", stdin="v1.2.0\nv1.1.1\nv1.0.4\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "v1.1.1\n") + + def test_highest_tag_prints_nothing_when_no_tag_qualifies(self) -> None: + result = run_script("highest-tag", "1.0.0", stdin="v1.2.0\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + + def test_highest_tag_refuses_a_malformed_version(self) -> None: + self.assertNotEqual(run_script("highest-tag", "1.1", stdin="v1.0.0\n").returncode, 0) + self.assertNotEqual(run_script("highest-tag", stdin="v1.0.0\n").returncode, 0) + class CheckPrCommandLineTests(unittest.TestCase): """``check-pr`` as the PR workflows invoke it, against two CMakeLists.txt files.""" diff --git a/.github/workflows/prepare-release.yaml b/.github/workflows/prepare-release.yaml index c7222a9df..a6253b8b9 100644 --- a/.github/workflows/prepare-release.yaml +++ b/.github/workflows/prepare-release.yaml @@ -9,7 +9,9 @@ name: Prepare Release # on: master, or the version's release/. branch. # # Post-bump after a stable release is the SAME operation: dispatch this -# workflow again with the next development version (e.g. `1.1.0-dev`). +# workflow again on the branch it shipped from, with its next development +# version: master's (e.g. `1.2.0-dev`) or a release branch's next patch (e.g. +# `1.1.0` -> `1.1.1-dev`). on: workflow_dispatch: inputs: diff --git a/docs/release-workflow.md b/docs/release-workflow.md index e54553bf9..0a066e2c9 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -104,7 +104,9 @@ Two human gates, two dispatches: flip does not re-fire `release: published` — no recursion, by design.) **Post-bump** after a stable release is the same operation as step 1: dispatch -`Prepare Release` again with the next development version (e.g. `1.1.0-dev`). +`Prepare Release` again on the branch it shipped from, with its next development +version: master's (e.g. `1.2.0-dev`) or a release branch's next patch (`1.1.0` → +`1.1.1-dev`). ### Release branches From 71fe87a8c903c3c59bcd2dd2835bf86a0e4e25e4 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 5 Nov 2026 12:45:14 -0600 Subject: [PATCH 4/4] ci: serialize release publication across tags Publish Release's concurrency group is per tag, so two releases publishing together could each choose the latest label from a release list the other was about to change, leaving it on the lower version. The per-tag job, renamed attach-assets, verifies and uploads the assets, and on the hand-pushed-tag fallback creates the draft itself. Publishing and the label move to a publish-release job whose concurrency group is shared by every tag, with queue: max so each waiting release publishes in turn instead of replacing the one queued before it. --- .github/workflows/release.yaml | 69 ++++++++++++++++++++++++---------- docs/release-workflow.md | 17 +++++---- 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2886b51c9..d93eda0f6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -138,8 +138,8 @@ jobs: echo "prerelease=$prerelease" } >> "$GITHUB_OUTPUT" - publish-release: - name: Publish GitHub Release + attach-assets: + name: Verify & attach release assets needs: validate-tag runs-on: ubuntu-latest # tag-release.yaml dispatches this workflow at the same moment it dispatches @@ -217,7 +217,7 @@ jobs: # build.yaml's verification of the same package set. tools/packaging/tests/verify-all.sh dist "$VERSION" - - name: Publish release + - name: Attach release assets run: | set -euo pipefail @@ -240,6 +240,51 @@ jobs: ) cat "dist/wire-cdt-${VERSION}-checksums.txt" + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + # Strategy C: tag-release.yaml already created the DRAFT release + # (title, notes and the prerelease flag are set there). + gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --clobber + else + # Fallback only (a human-pushed tag with no draft prepared): create the + # draft here, and publish-release makes it public like any other. + prerelease_flag="" + [[ "$PRERELEASE" == "true" ]] && prerelease_flag="--prerelease" + gh release create "$TAG" dist/* \ + --repo "$GITHUB_REPOSITORY" \ + --draft \ + --title "$TAG" \ + --generate-notes \ + --verify-tag $prerelease_flag + fi + + # Publishing and the `latest` label run in one queue shared by every tag. The + # workflow's concurrency group is per tag, so without it two releases could each + # pick the label from a list the other is about to change and leave it on the + # lower version. Only this job is serialized; verification and asset uploads + # stay per tag. + publish-release: + name: Publish GitHub Release + needs: [validate-tag, attach-assets] + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: publish-releases + # FIFO: each waiting release publishes in turn instead of replacing the one + # queued before it. + queue: max + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.validate-tag.outputs.tag }} + steps: + - name: Publish the release + run: | + set -euo pipefail + + # A GITHUB_TOKEN draft flip does NOT re-fire `release: published`, so + # there is no recursion. + # # The `latest` label belongs to the highest published stable version. A # patch cut from an older release/. branch after a newer # line shipped must not take it. The flag is explicit either way, @@ -262,23 +307,7 @@ jobs: fi fi - if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - # Strategy C: tag-release.yaml already created the DRAFT release - # (title, notes and the prerelease flag are set there). Attach the - # assets, then make it public. A GITHUB_TOKEN draft flip does NOT - # re-fire `release: published`, so there is no recursion. - gh release upload "$TAG" dist/* --repo "$GITHUB_REPOSITORY" --clobber - gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false $latest_flag - else - # Fallback only (a human-pushed tag with no draft prepared). - prerelease_flag="" - [[ "$PRERELEASE" == "true" ]] && prerelease_flag="--prerelease" - gh release create "$TAG" dist/* \ - --repo "$GITHUB_REPOSITORY" \ - --title "$TAG" \ - --generate-notes \ - --verify-tag $prerelease_flag $latest_flag - fi + gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false $latest_flag # A release published by hand may have taken the label; hand it back. if [[ "$latest_flag" == "--latest=false" ]]; then gh release edit "$highest" --repo "$GITHUB_REPOSITORY" --latest diff --git a/docs/release-workflow.md b/docs/release-workflow.md index 0a066e2c9..1fb062475 100644 --- a/docs/release-workflow.md +++ b/docs/release-workflow.md @@ -98,10 +98,11 @@ Two human gates, two dispatches: dispatches `Publish Release` with the tag. 6. `Publish Release` checks the tag sits on master or its version's `release/.` branch, waits for both tag-ref builds, downloads - both package artifacts, verifies each artifact explicitly, attaches them plus - the checksums file to the draft, and flips the draft public. The `latest` - label goes to the highest published stable version. (A GITHUB_TOKEN draft - flip does not re-fire `release: published` — no recursion, by design.) + both package artifacts, verifies each artifact explicitly, and attaches them + plus the checksums file to the draft. Its `publish-release` job then flips + the draft public and gives the `latest` label to the highest published stable + version; that job runs in one queue shared by every tag. (A GITHUB_TOKEN + draft flip does not re-fire `release: published` — no recursion, by design.) **Post-bump** after a stable release is the same operation as step 1: dispatch `Prepare Release` again on the branch it shipped from, with its next development @@ -165,7 +166,9 @@ publish on it. one run the same checks as PRs into master. Pushes to `release/prep-*` build nothing; their PRs run the checks. - **The `latest` label.** It goes to the highest published stable version, so a - `1.1.1` cut after `1.2.0` leaves `1.2.0` as latest. + `1.1.1` cut after `1.2.0` leaves `1.2.0` as latest. Releases publish one at a + time, in the `publish-releases` queue, so two published together cannot + leave it on the lower one. - **Release notes.** `generate-notes` picks the previous release itself, so the notes of a patch cut from an older release branch can start from a newer line's release. @@ -177,8 +180,8 @@ publish on it. - Wrong tag or wrong content: delete the draft and the tag, then cut the next `-rcN`. - `Publish Release` also keeps a `push: tags` trigger and a - `gh release create` fallback branch for a human-pushed stable tag with no - draft prepared. + `gh release create --draft` fallback branch for a human-pushed stable tag with + no draft prepared; `publish-release` then publishes it like any other. ## Human vs system