From 5184ae4aa5f856889cc6c7e087c147d86174bba4 Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:14:23 +0800 Subject: [PATCH 01/10] feat(release-tools): decide settlement state from branch facts Adds settle_state(), the settle-state subcommand and the route dispatch mapping plus the sweep filter that keeps a tag-without-Release branch eligible. Unit tests run under unittest discovery. --- .../actions/release-tools/release_tools.py | 117 +++++++++- .../release-tools/test_release_tools.py | 219 +++++++++++++++++- .../release-tools/test_settle_state.py | 96 ++++++++ 3 files changed, 424 insertions(+), 8 deletions(-) create mode 100644 .github/actions/release-tools/test_settle_state.py diff --git a/.github/actions/release-tools/release_tools.py b/.github/actions/release-tools/release_tools.py index 40c7dc7..ae7c60f 100644 --- a/.github/actions/release-tools/release_tools.py +++ b/.github/actions/release-tools/release_tools.py @@ -12,6 +12,7 @@ other tags (v1.2.0-rc1) are named on stderr and ignored check-order VERSION LATEST_TAG_OR_EMPTY -> exit 0 if VERSION may be published after LATEST, else print the refusal on stdout and exit 1 + settle-state --version VERSION ... -> settlement state, then a one-line reason read-file PATH PATTERN -> version found in the file bump-file PATH PATTERN VERSION -> rewrite in place, print old version notes REPO VERSION DATE -> stdin: "\t" lines; stdout: markdown @@ -118,6 +119,38 @@ def order_refusal(version: str, latest: str) -> str | None: return None +# --- settlement ------------------------------------------------------------- + + +def settle_state( + version: str, + branch_tip: str, + candidate_merge_sha: str, + candidate_is_ancestor: bool, + open_settle_pr: bool, + published_tag_exists: bool, + release_exists: bool, + published_tag_sha: str = "", + *, + section_matches: bool = True, +) -> tuple[str, str]: + """Decide settlement from caller-collected facts, without external reads.""" + version = normalize_version(version) + if not candidate_is_ancestor: + return "blocked", f"candidate merge {candidate_merge_sha} is not an ancestor of tip {branch_tip}" + if published_tag_exists: + if published_tag_sha and published_tag_sha != candidate_merge_sha: + return "blocked", f"v{version} points to {published_tag_sha}, not candidate merge {candidate_merge_sha}" + if release_exists: + return "published", f"v{version} already has a published tag and Release" + return "resume", f"v{version} has a published tag but no Release" + if branch_tip != candidate_merge_sha or open_settle_pr: + return "needs-settle-pr", f"v{version} has commits beyond the candidate merge or an open settle PR" + if not section_matches: + return "needs-settle-pr", f"v{version} has a stale changelog section" + return "clean", f"v{version} is at the candidate merge with no open settle PR or published tag" + + # --- release routing -------------------------------------------------------- @@ -249,16 +282,67 @@ def route(data: dict) -> dict: elif kind == "settle" and event == "workflow_dispatch": selected = _field(data, "dispatch_version", "") selected = normalize_version(selected) if selected else "" - for branch in _items(data, "branches"): + branches = _items(data, "branches") + if selected: + name = f"{prefix}{selected}" + branch = next((branch for branch in branches if branch.get("name") == name), {}) + if not branch: + result.update( + reason=f"release branch {name} does not exist", + version=selected, release_branch=name, + ) + return result + facts = {**data, **branch} + missing = [] + if not facts.get("candidate_merge_sha"): + missing.append("candidate_merge_sha") + if "candidate_is_ancestor" not in facts: + missing.append("candidate_is_ancestor") + if missing: + raise ValueError(f"v{selected} missing settlement facts: {', '.join(missing)}") + tip = facts.get("tip") or facts.get("branch_tip") or "" + tag_sha = facts.get("published_tag_sha") or facts.get("tag_commit") or "" + state, reason = settle_state( + selected, tip, facts["candidate_merge_sha"], + bool(facts["candidate_is_ancestor"]), + bool(facts.get("open_settle_pr")), + bool(facts.get("published_tag_exists", facts.get("tag_exists"))), + bool(facts.get("release_exists")), + tag_sha, + ) + action = {"clean": "publish", "resume": "publish", "needs-settle-pr": "refresh", + "published": "skip", "blocked": "skip"}[state] + result.update( + action=action, reason=reason, version=selected, release_branch=name, + commit=(tag_sha or tip) if state == "resume" else tip, + pr_number=(facts.get("candidate_pr_number") or facts.get("candidatePRNumber") + or facts.get("pr_number")), + ) + if action == "publish": + result["settle_source"] = "dispatch" + elif action == "refresh": + result["targets"] = [ + {"version": selected, "release_branch": name, "commit": tip}, + ] + if state == "published": + print(f"skipping {name} (v{selected}): {reason}", file=sys.stderr) + return result + for branch in branches: name = branch.get("name") - if not isinstance(name, str) or not name.startswith(prefix) or branch.get("tag_exists"): + if not isinstance(name, str) or not name.startswith(prefix): continue version = _branch_version(name, prefix) if version is None: continue - if (branch.get("version_at_tip") == version - and order_refusal(version, _field(data, "latest_tag", "")) is None - and (not selected or version == selected)): + tagged = branch.get("published_tag_exists", branch.get("tag_exists")) + if tagged and branch.get("release_exists"): + print( + f"skipping {name} (v{version}): published tag and Release already exist", + file=sys.stderr, + ) + continue + if (tagged or (branch.get("version_at_tip") == version + and order_refusal(version, _field(data, "latest_tag", "")) is None)): result["targets"].append({ "version": version, "release_branch": name, "commit": branch.get("tip") or "", }) @@ -434,6 +518,15 @@ def main(argv: list[str]) -> int: s.add_argument("version") s.add_argument("latest") + s = sub.add_parser("settle-state") + s.add_argument("--version", required=True) + s.add_argument("--branch-tip", required=True) + s.add_argument("--candidate-merge-sha", required=True) + for flag in ("candidate-is-ancestor", "open-settle-pr", "published-tag-exists", "release-exists"): + s.add_argument(f"--{flag}", choices=("true", "false"), required=True) + s.add_argument("--published-tag-sha", default="") + s.add_argument("--section-matches", choices=("true", "false"), default="true") + s = sub.add_parser("read-file") s.add_argument("path") s.add_argument("pattern") @@ -456,6 +549,7 @@ def main(argv: list[str]) -> int: s = sub.add_parser("changelog-extract") s.add_argument("path") s.add_argument("version") + s.add_argument("--include-heading", action="store_true") s = sub.add_parser("changelog-copy") s.add_argument("src") @@ -480,6 +574,16 @@ def main(argv: list[str]) -> int: if refusal: print(refusal) return 1 + elif a.cmd == "settle-state": + state, reason = settle_state( + a.version, a.branch_tip, a.candidate_merge_sha, + a.candidate_is_ancestor == "true", a.open_settle_pr == "true", + a.published_tag_exists == "true", a.release_exists == "true", + a.published_tag_sha, + section_matches=a.section_matches == "true", + ) + print(state) + print(reason) elif a.cmd == "read-file": print(read_version_file(Path(a.path).read_text(), a.pattern)) elif a.cmd == "bump-file": @@ -509,7 +613,8 @@ def main(argv: list[str]) -> int: dst.write_text(new) print(what) elif a.cmd == "changelog-extract": - body = extract_changelog_section(Path(a.path).read_text(), a.version) + extract = changelog_section_text if a.include_heading else extract_changelog_section + body = extract(Path(a.path).read_text(), a.version) if body is None: print(f"no section for v{normalize_version(a.version)} in {a.path}", file=sys.stderr) return 1 diff --git a/.github/actions/release-tools/test_release_tools.py b/.github/actions/release-tools/test_release_tools.py index 314bc05..3a289bb 100644 --- a/.github/actions/release-tools/test_release_tools.py +++ b/.github/actions/release-tools/test_release_tools.py @@ -80,7 +80,7 @@ def fixture(self, kind="settle"): "pulls": [{ "number": 42, "merged_at": "2026-10-27T00:00:00Z", "merge_commit_sha": self.SHA, "base": {"ref": ref}, - "head": {"ref": "chore/release-candidate-v1.2.0" if candidate + "head": {"ref": "chore/release-candidate-1.2.0" if candidate else "chore/release-settle-v1.2.0"}, "user": {"login": "mega-gutenberg[bot]"}, "body": f"", @@ -271,8 +271,10 @@ def dispatch_fixture(self): data = self.fixture() data.update(event="workflow_dispatch", branches=[ {"name": "release-v1.2.0", "tip": self.SHA, "version_at_tip": "1.2.0", "tag_exists": False}, - {"name": "release-v1.3.0", "tip": self.PARENT, "version_at_tip": "1.3.0", "tag_exists": True}, + {"name": "release-v1.3.0", "tip": self.PARENT, "version_at_tip": "1.3.0", + "tag_exists": True, "release_exists": True}, ]) + data.update(candidate_merge_sha="c" * 40, candidate_is_ancestor=True) return data def test_dispatch_two_branches_excludes_tagged_branch(self): @@ -512,6 +514,219 @@ def test_section_text_and_copy_between_files(self): def test_extract_missing(self): self.assertIsNone(rt.extract_changelog_section(self.OLD, "9.9.9")) + def test_generated_undated_section_round_trip_is_unchanged(self): + text = "# Changelog\n\n## v0.2.0\n\n### Fixes\n\n- b (`bbbbbbbbbb`)\n\n" + self.OLD.split("\n\n", 1)[1] + lines = ["b" * 40 + "\tfix: b"] + section = rt.generate_notes("o/r", "0.2.0", "", lines) + + new, what = rt.insert_changelog_section(text, "0.2.0", section) + + self.assertEqual((new, what), (text, "replaced")) + + def test_generated_undated_section_replaces_dated_heading(self): + text = "# Changelog\n\n" + self.SEC + section = rt.generate_notes("o/r", "0.2.0", "", ["b" * 40 + "\tfix: b"]) + + new, what = rt.insert_changelog_section(text, "0.2.0", section) + + self.assertEqual(what, "replaced") + self.assertNotEqual(new, text) + self.assertEqual(new, "# Changelog\n\n" + section) + + def test_extract_cli_can_include_heading(self): + for flags, expected in ( + ([], "### Fixes\n\n- b (`bbbbbbbbbb`)\n"), + (["--include-heading"], self.SEC), + ): + with self.subTest(flags=flags): + stdout = io.StringIO() + with patch.object(rt.Path, "read_text", return_value=self.SEC), contextlib.redirect_stdout(stdout): + code = rt.main(["changelog-extract", "CHANGELOG.md", "0.2.0", *flags]) + self.assertEqual(code, 0) + self.assertEqual(stdout.getvalue(), expected) + + +class SettlementSections(unittest.TestCase): + def test_matching_section_keeps_candidate_merge_clean(self): + for facts in ({}, {"section_matches": True}): + with self.subTest(facts=facts): + state, _ = rt.settle_state("1.2.0", "a" * 40, "a" * 40, True, False, False, False, **facts) + self.assertEqual(state, "clean") + + def test_stale_section_at_candidate_merge_needs_settle_pr(self): + state, reason = rt.settle_state( + "1.2.0", "a" * 40, "a" * 40, True, False, False, False, + section_matches=False, + ) + self.assertEqual(state, "needs-settle-pr") + self.assertIn("stale changelog section", reason) + + def test_cli_section_matches_defaults_true_and_accepts_false(self): + args = [ + "settle-state", "--version", "1.2.0", "--branch-tip", "a" * 40, + "--candidate-merge-sha", "a" * 40, "--candidate-is-ancestor", "true", + "--open-settle-pr", "false", "--published-tag-exists", "false", + "--release-exists", "false", + ] + for flags, expected in ( + ([], "clean"), + (["--section-matches", "true"], "clean"), + (["--section-matches", "false"], "needs-settle-pr"), + ): + with self.subTest(flags=flags): + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + code = rt.main([*args, *flags]) + self.assertEqual(code, 0) + self.assertEqual(stdout.getvalue().splitlines()[0], expected) + + +class DispatchSettlement(unittest.TestCase): + def fixture(self, **facts): + branch = { + "name": "release-v1.2.0", "tip": "a" * 40, "version_at_tip": "1.2.0", + "candidate_merge_sha": "a" * 40, "candidate_is_ancestor": True, + "open_settle_pr": False, "tag_exists": False, "release_exists": False, + } + branch.update(facts) + return { + "kind": "settle", "event": "workflow_dispatch", "dispatch_version": "v1.2.0", + "latest_tag": "v9.0.0", "branches": [branch], + } + + def test_predicate_states_map_to_dispatch_actions_and_reasons(self): + cases = [ + ("clean", {}, "publish"), + ("needs-settle-pr", {"tip": "b" * 40}, "refresh"), + ("resume", {"tag_exists": True}, "publish"), + ("published", {"tag_exists": True, "release_exists": True}, "skip"), + ("blocked", {"candidate_is_ancestor": False}, "skip"), + ] + for expected_state, facts, action in cases: + with self.subTest(state=expected_state): + data = self.fixture(**facts) + branch = data["branches"][0] + state, reason = rt.settle_state( + "1.2.0", branch["tip"], branch["candidate_merge_sha"], + branch["candidate_is_ancestor"], branch["open_settle_pr"], + branch["tag_exists"], branch["release_exists"], + ) + self.assertEqual(state, expected_state) + result = rt.route(data) + self.assertEqual((result["action"], result["reason"]), (action, reason)) + self.assertEqual(result.get("settle_source"), "dispatch" if action == "publish" else None) + self.assertEqual((result["version"], result["release_branch"], result["commit"]), + ("1.2.0", branch["name"], branch["tip"])) + self.assertEqual(result["targets"], [ + {"version": "1.2.0", "release_branch": branch["name"], "commit": branch["tip"]}, + ] if action == "refresh" else []) + + def test_candidate_pr_number_is_optional(self): + self.assertIsNone(rt.route(self.fixture())["pr_number"]) + for key in ("candidate_pr_number", "candidatePRNumber", "pr_number"): + with self.subTest(key=key): + self.assertEqual(rt.route(self.fixture(**{key: 42}))["pr_number"], 42) + + def test_resume_publishes_tagged_commit_when_tip_is_ahead(self): + for key in ("published_tag_sha", "tag_commit", None): + with self.subTest(tag_sha_key=key): + facts = {"tip": "b" * 40, "tag_exists": True} + if key: + facts[key] = "a" * 40 + result = rt.route(self.fixture(**facts)) + self.assertEqual(result["action"], "publish") + self.assertEqual(result["settle_source"], "dispatch") + self.assertEqual(result["commit"], "a" * 40 if key else "b" * 40) + + def test_selected_version_always_reaches_predicate(self): + for facts in ( + {"version_at_tip": None}, + {"tag_exists": True, "release_exists": True}, + ): + with self.subTest(facts=facts): + with patch.object(rt, "settle_state", return_value=("blocked", "blocked")) as predicate: + result = rt.route(self.fixture(**facts)) + predicate.assert_called_once() + self.assertEqual((result["action"], result["reason"]), ("skip", "blocked")) + + def test_missing_branch_facts_cannot_publish(self): + data = self.fixture() + data["branches"] = [] + result = rt.route(data) + self.assertEqual(result["action"], "skip") + self.assertIn("release-v1.2.0 does not exist", result["reason"]) + + def test_push_without_merged_settle_pr_cannot_publish(self): + data = self.fixture() + data.update(event="push", ref_name="release-v1.2.0", sha="a" * 40, + version_at_sha="1.2.0", latest_tag="", pulls=[]) + with patch.object(rt, "settle_state", side_effect=AssertionError("dispatch only")): + result = rt.route(data) + self.assertEqual(result["action"], "refresh") + self.assertNotIn("settle_source", result) + + def test_sweep_keeps_tag_without_release_and_summarizes_published_branch(self): + data = self.fixture(tag_exists=True, version_at_tip=None) + data["dispatch_version"] = "" + data["branches"].append({ + "name": "release-v1.3.0", "tip": "b" * 40, "version_at_tip": "1.3.0", + "tag_exists": True, "release_exists": True, + }) + summary = io.StringIO() + with contextlib.redirect_stderr(summary): + result = rt.route(data) + self.assertEqual(result["action"], "refresh") + self.assertEqual(result["targets"], [ + {"version": "1.2.0", "release_branch": "release-v1.2.0", "commit": "a" * 40}, + ]) + self.assertEqual(len(summary.getvalue().splitlines()), 1) + self.assertIn("release-v1.3.0 (v1.3.0)", summary.getvalue()) + self.assertIn("published tag and Release", summary.getvalue()) + + +class DispatchSettlementFacts(unittest.TestCase): + def fixture(self): + return { + "kind": "settle", "event": "workflow_dispatch", "dispatch_version": "v1.2.0", + "branches": [{ + "name": "release-v1.2.0", "tip": "a" * 40, + "version_at_tip": "1.2.0", "tag_exists": False, + }], + } + + def test_without_facts_raises(self): + for facts, missing in ( + ({}, ["candidate_merge_sha", "candidate_is_ancestor"]), + ({"candidate_merge_sha": "", "candidate_is_ancestor": True}, + ["candidate_merge_sha"]), + ({"candidate_merge_sha": "a" * 40}, ["candidate_is_ancestor"]), + ): + with self.subTest(facts=facts): + data = self.fixture() + data["branches"][0].update(facts) + with self.assertRaises(ValueError) as raised: + rt.route(data) + self.assertIn("v1.2.0", str(raised.exception)) + for key in missing: + self.assertIn(key, str(raised.exception)) + + def test_with_facts_publishes(self): + data = self.fixture() + data["branches"][0].update( + candidate_merge_sha="a" * 40, candidate_is_ancestor=True, open_settle_pr=False, + ) + result = rt.route(data) + self.assertEqual(result["action"], "publish") + self.assertEqual(result["settle_source"], "dispatch") + + def test_missing_branch_still_skips(self): + data = self.fixture() + data["branches"] = [] + result = rt.route(data) + self.assertEqual(result["action"], "skip") + self.assertIn("release-v1.2.0", result["reason"]) + self.assertIn("does not exist", result["reason"]) + if __name__ == "__main__": unittest.main() diff --git a/.github/actions/release-tools/test_settle_state.py b/.github/actions/release-tools/test_settle_state.py new file mode 100644 index 0000000..1d0caff --- /dev/null +++ b/.github/actions/release-tools/test_settle_state.py @@ -0,0 +1,96 @@ +import contextlib +import io +import unittest + +import release_tools as rt + + +CANDIDATE = "a" * 40 +AHEAD = "b" * 40 +OTHER = "c" * 40 + + +class SettleState(unittest.TestCase): + def test_settle_states(self): + cases = [ + (CANDIDATE, True, False, False, False, "", "clean"), + (AHEAD, True, False, False, False, "", "needs-settle-pr"), + (CANDIDATE, True, True, False, False, "", "needs-settle-pr"), + (CANDIDATE, True, False, True, False, CANDIDATE, "resume"), + (CANDIDATE, True, False, True, True, CANDIDATE, "published"), + (OTHER, False, False, False, False, "", "blocked"), + (CANDIDATE, True, False, True, False, OTHER, "blocked"), + (CANDIDATE, True, False, True, True, OTHER, "blocked"), + (OTHER, False, True, True, True, CANDIDATE, "blocked"), + (AHEAD, True, True, True, False, CANDIDATE, "resume"), + (AHEAD, True, True, True, True, CANDIDATE, "published"), + (CANDIDATE, True, False, True, False, "", "resume"), + (CANDIDATE, True, False, True, True, "", "published"), + (CANDIDATE, True, False, False, True, OTHER, "clean"), + ] + for tip, ancestor, open_pr, tag, release, tag_sha, expected in cases: + with self.subTest(tip=tip, ancestor=ancestor, open_pr=open_pr, + tag=tag, release=release, tag_sha=tag_sha): + state, reason = rt.settle_state( + "v1.2.3", tip, CANDIDATE, ancestor, open_pr, tag, release, tag_sha, + ) + self.assertEqual(state, expected) + self.assertTrue(reason) + self.assertEqual(len(reason.splitlines()), 1) + + argv = [ + "settle-state", "--version", "v1.2.3", "--branch-tip", tip, + "--candidate-merge-sha", CANDIDATE, + "--candidate-is-ancestor", str(ancestor).lower(), + "--open-settle-pr", str(open_pr).lower(), + "--published-tag-exists", str(tag).lower(), + "--release-exists", str(release).lower(), + ] + if tag_sha: + argv.extend(["--published-tag-sha", tag_sha]) + out = io.StringIO() + with contextlib.redirect_stdout(out): + self.assertEqual(rt.main(argv), 0) + self.assertEqual(out.getvalue().splitlines(), [state, reason]) + + def test_each_boolean_changes_state(self): + cases = [ + (0, (True, False, False, False), "blocked", "clean"), + (1, (True, False, False, False), "clean", "needs-settle-pr"), + (2, (True, False, False, False), "clean", "resume"), + (3, (True, False, True, False), "resume", "published"), + ] + for index, args, expected_false, expected_true in cases: + for value, expected in ((False, expected_false), (True, expected_true)): + with self.subTest(index=index, value=value): + changed = list(args) + changed[index] = value + self.assertEqual( + rt.settle_state("1.2.3", CANDIDATE, CANDIDATE, *changed)[0], + expected, + ) + + def test_version_is_normalized(self): + args = (CANDIDATE, CANDIDATE, True, False, True, False) + self.assertEqual(rt.settle_state("v1.2.3", *args), rt.settle_state("1.2.3", *args)) + with self.assertRaises(ValueError): + rt.settle_state("not-a-version", *args) + + def test_cli_rejects_non_boolean_values(self): + for flag in ("candidate-is-ancestor", "open-settle-pr", + "published-tag-exists", "release-exists"): + with self.subTest(flag=flag): + argv = [ + "settle-state", "--version", "1.2.3", "--branch-tip", CANDIDATE, + "--candidate-merge-sha", CANDIDATE, + "--candidate-is-ancestor", "true", "--open-settle-pr", "false", + "--published-tag-exists", "false", "--release-exists", "false", + ] + argv[argv.index(f"--{flag}") + 1] = "yes" + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as exc: + rt.main(argv) + self.assertEqual(exc.exception.code, 2) + + +if __name__ == "__main__": + unittest.main() From fb07c3e96dcd933a41a401f210ed20731ca2403d Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:14:23 +0800 Subject: [PATCH 02/10] feat(release-settle): no-op on a clean branch and publish from state Generates an undated note section, computes the settlement state before touching anything, and skips the ensure-PR step entirely when the branch is unchanged. Empty changelog_file is now legal. --- .github/actions/release-settle/README.md | 44 +++--- .github/actions/release-settle/action.yml | 164 ++++++++++++++++++---- 2 files changed, 165 insertions(+), 43 deletions(-) diff --git a/.github/actions/release-settle/README.md b/.github/actions/release-settle/README.md index fde81d2..6c19126 100644 --- a/.github/actions/release-settle/README.md +++ b/.github/actions/release-settle/README.md @@ -3,7 +3,7 @@ `uses: megaeth-labs/.github/.github/actions/release-settle@main` -Maintain a settle PR for 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 open or refresh a `chore/release-settle-vX.Y.Z` PR onto the release branch without rewriting its history. New commits on the release branch refresh this PR automatically; merging it is the settlement decision and release-publish then tags the merge commit once. Run as a step in a job the consumer owns; 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. +Maintain a settle PR for 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 under the candidate's undated `## vX.Y.Z` heading, and open or refresh a `chore/release-settle-vX.Y.Z` PR onto the release branch without rewriting its history. New commits on the release branch refresh this PR automatically; merging it is the settlement decision and release-publish then tags the merge commit once. Run as a step in a job the consumer owns; 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. Family: [Release pipeline](../RELEASE.md). @@ -20,7 +20,7 @@ Family: [Release pipeline](../RELEASE.md). | `version_pattern` | no | `plain` | How the version is stored in `version_file`: plain, toml or json. | | `release_branch` | no | | Defaults to `X.Y.Z`. | | `release_branch_prefix` | no | `release-v` | Release branch name prefix; the branch is `X.Y.Z`. | -| `changelog_file` | no | `CHANGELOG.md` | Changelog the entry is written to (Keep-a-Changelog style, newest first). | +| `changelog_file` | no | `CHANGELOG.md` | Changelog the entry is written to (Keep-a-Changelog style, newest first); empty disables changelog writes. | | `pr_author` | no | `mega-gutenberg[bot]` | Author of the bot-maintained settle PR; PRs by other authors are superseded. | | `pr_labels` | no | | Comma-separated labels for the settle PR (for repos whose label gates apply to release branches too). | | `git_user_name` | no | `mega-gutenberg[bot]` | Committer identity for the commits this action makes. | @@ -40,11 +40,12 @@ Family: [Release pipeline](../RELEASE.md). ## What it runs -1. Configure git identity 1. Guard 1. Compare workflows with the default branch -1. Generate release notes -1. Ensure the settle PR +1. Compute settlement state +1. Configure git identity *(only if `steps.state.outputs.state == 'needs-settle-pr'`)* +1. Generate release notes *(only if `steps.state.outputs.state == 'needs-settle-pr'`)* +1. Ensure the settle PR *(only if `steps.state.outputs.state == 'needs-settle-pr'`)* ## Errors it reports @@ -56,6 +57,7 @@ Family: [Release pipeline](../RELEASE.md). - `$COMMIT is not the tip of $branch (tip is $tip). Settle the tip, or move the branch first.` - `$FILE at $COMMIT says $actual, expected $version` - `$version is not newer than the latest tag ${latest:-}` +- `${decision#*$` - `settle PR not ensured; stopped while $stage` - `$BRANCH moved to ${tip:-} after this refresh of $COMMIT started (a settle PR merge or a new push); not touching the settle PR, the run for the new tip refreshes it` - `could not merge $COMMIT into $head; close the settle PR so the next run recreates it` @@ -65,9 +67,9 @@ Family: [Release pipeline](../RELEASE.md). ## Example -Direct mode, as in `workflow-templates/release-settle.yml` — the job -declares `environment: release`, so the run waits for that environment's -reviewers before this step runs: +As used by `workflow-templates/release-settle.yml`. The refresh job needs no +environment; the publish job that tags and creates the Release declares +`environment: publish`, whose required reviewers authorise the tag: ```yaml - uses: megaeth-labs/.github/.github/actions/release-settle@main @@ -78,25 +80,33 @@ reviewers before this step runs: version_file: Cargo.toml version_pattern: toml changelog_file: CHANGELOG.md - settle_mode: direct - # settlers: admin # optional extra restriction on who may start a settle ``` -Leave out `settle_mode` (and the job's `environment:`) to settle by PR -instead. +The action itself never tags and never creates a Release: it only maintains +`chore/release-settle-vX.Y.Z`. Publishing is `release-publish`, called by the +template's publish job on a clean dispatch or on the settle PR's merge. ## Notes +- Before generating notes or ensuring the PR, `settle-state` compares the + release-branch tip with the merged candidate PR, checks ancestry, and reads + the open settle PR, tag and GitHub Release facts. If the branch is unchanged + at the candidate merge with no open settle PR or tag, the action succeeds + with step outputs `settled=false` and `state=clean`. The run summary says + nothing was written: git identity configuration, notes generation and the + entire ensure-PR step are skipped, so no settle branch, commit, PR creation, + update or comment is made. Only `needs-settle-pr` reaches the existing PR flow, + including its deliberate `--allow-empty` commit when a fresh branch records + settlement. - Guards, in order: `commit` is a full SHA; the release branch exists; the tag does not; `commit` is the branch tip; the version file at `commit` says the version; the version is newer than the latest `v*` tag. Then a warning if `commit` lacks a workflow the default branch has (a `release` event runs `on-release.yml` from the tag's tree). -- `direct`: the settle job's `release` environment reviewers are the gate - (`settlers: any`, the default); a login list or `admin` in `settlers` - additionally checks the dispatcher. The dated entry is committed straight - onto the release branch (the app must bypass its ruleset) and - `release-publish` runs in the same job with `commit` and `version` set. +- This action never tags and never creates a Release: it maintains + `chore/release-settle-vX.Y.Z` only. `release-publish` does the tagging, run + by the template's publish job on a clean dispatch (where the `publish` + environment's required reviewers are the gate) or on the settle PR's merge. - `pr`: pushes `chore/release-settle-vX.Y.Z` and opens the settle PR, titled `chore(release): settle vX.Y.Z — merging publishes the release`. Its body opens with an `[!IMPORTANT]` alert saying that merging it settles diff --git a/.github/actions/release-settle/action.yml b/.github/actions/release-settle/action.yml index 1778556..dc87ceb 100644 --- a/.github/actions/release-settle/action.yml +++ b/.github/actions/release-settle/action.yml @@ -2,8 +2,8 @@ name: Release Settle description: >- Maintain a settle PR for 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 open or refresh a + the commits since the previous tag, write them into the changelog under + the candidate's undated `## vX.Y.Z` heading, and open or refresh a `chore/release-settle-vX.Y.Z` PR onto the release branch without rewriting its history. New commits on the release branch refresh this PR automatically; merging it is the settlement decision and release-publish then tags the merge @@ -43,7 +43,7 @@ inputs: required: false default: release-v changelog_file: - description: "Changelog the entry is written to (Keep-a-Changelog style, newest first)." + description: "Changelog the entry is written to (Keep-a-Changelog style, newest first); empty disables changelog writes." required: false default: CHANGELOG.md pr_author: @@ -77,15 +77,6 @@ outputs: runs: using: composite steps: - - name: Configure git identity - shell: bash - env: - NAME: ${{ inputs.git_user_name }} - EMAIL: ${{ inputs.git_user_email }} - run: | - git config user.name "$NAME" - git config user.email "$EMAIL" - - name: Guard id: guard shell: bash @@ -161,7 +152,111 @@ runs: fi GITHUB_AUTH_COMMAND + - name: Compute settlement state + id: state + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + VERSION: ${{ steps.guard.outputs.version }} + BRANCH: ${{ steps.guard.outputs.branch }} + COMMIT: ${{ inputs.commit }} + PREVIOUS: ${{ steps.guard.outputs.previous_tag }} + CHANGELOG: ${{ inputs.changelog_file }} + PR_AUTHOR: ${{ inputs.pr_author }} + TOOLS: ${{ github.action_path }}/../release-tools/release_tools.py + run: | + set -euo pipefail + candidates="$(gh pr list --state merged --head "chore/release-candidate-$VERSION" \ + --json mergeCommit,body,author,mergedAt)" + candidate="$(python3 - "$VERSION" "$PR_AUTHOR" "$candidates" <<'PY' + import json, sys + + def normalized(login): + # `gh pr list --json author` reports an app as "app/", while the + # REST API and every other check here use "[bot]"; compare one form. + login = login or "" + if login.startswith("app/"): + return login[4:] + "[bot]" + return login + + marker = f"" + expected = normalized(sys.argv[2]) + candidates = [pr for pr in json.loads(sys.argv[3]) + if marker in (pr.get("body") or "") + and (not expected or normalized(pr["author"]["login"]) == expected)] + if not candidates: + sys.exit("no merged candidate PR found for this version") + print(max(candidates, key=lambda pr: pr["mergedAt"])["mergeCommit"]["oid"]) + PY + )" + ancestor=false + ancestry_status=0 + git merge-base --is-ancestor "$candidate" "$COMMIT" || ancestry_status=$? + case "$ancestry_status" in + 0) ancestor=true ;; + 1) ;; + *) exit "$ancestry_status" ;; + esac + open_pr="$(gh pr list --state open --head "chore/release-settle-v$VERSION" \ + --base "$BRANCH" --json number --jq 'length > 0')" + tag_exists=false + tag_sha="" + if git show-ref --verify --quiet "refs/tags/v$VERSION"; then + tag_exists=true + tag_sha="$(git rev-parse "v$VERSION^{commit}")" + fi + release_exists=false + releases="$(gh api --paginate "repos/$GITHUB_REPOSITORY/releases" --jq \ + ".[] | select(.tag_name == \"v$VERSION\") | .id")" + [[ -z "$releases" ]] || release_exists=true + section_matches=true + if [[ -n "$CHANGELOG" ]]; then + range="$COMMIT" + [[ -n "$PREVIOUS" ]] && range="$PREVIOUS..$COMMIT" + regenerated="$RUNNER_TEMP/release-regenerated-v$VERSION.md" + committed="$RUNNER_TEMP/release-committed-v$VERSION.md" + git log --no-merges --format='%H%x09%s' "$range" \ + | python3 "$TOOLS" notes "$GITHUB_REPOSITORY" "$VERSION" "" > "$regenerated" + section_matches=false + if git show "$COMMIT:$CHANGELOG" > "$RUNNER_TEMP/changelog-at-tip.md" \ + && python3 "$TOOLS" changelog-extract "$RUNNER_TEMP/changelog-at-tip.md" "$VERSION" \ + --include-heading > "$committed"; then + if [[ "$(sed 's/[[:space:]]*$//' "$regenerated")" == "$(sed 's/[[:space:]]*$//' "$committed")" ]]; then + section_matches=true + fi + fi + fi + decision="$(python3 "$TOOLS" settle-state --version "$VERSION" \ + --branch-tip "$COMMIT" --candidate-merge-sha "$candidate" \ + --candidate-is-ancestor "$ancestor" --open-settle-pr "$open_pr" \ + --published-tag-exists "$tag_exists" --release-exists "$release_exists" \ + --published-tag-sha "$tag_sha" --section-matches "$section_matches")" + state="${decision%%$'\n'*}" + echo "state=$state" >> "$GITHUB_OUTPUT" + case "$state" in + clean) + echo "settled=false" >> "$GITHUB_OUTPUT" + echo "Release branch \`$BRANCH\` is unchanged at the candidate merge; nothing was written." >> "$GITHUB_STEP_SUMMARY" + ;; + needs-settle-pr) ;; + resume|published) + echo "${decision#*$'\n'}; no settle PR was written." >> "$GITHUB_STEP_SUMMARY" + ;; + *) echo "::error::${decision#*$'\n'}"; exit 1 ;; + esac + + - name: Configure git identity + if: steps.state.outputs.state == 'needs-settle-pr' + shell: bash + env: + NAME: ${{ inputs.git_user_name }} + EMAIL: ${{ inputs.git_user_email }} + run: | + git config user.name "$NAME" + git config user.email "$EMAIL" + - name: Generate release notes + if: steps.state.outputs.state == 'needs-settle-pr' id: notes shell: bash env: @@ -175,11 +270,12 @@ runs: range="$COMMIT" [[ -n "$PREVIOUS" ]] && range="$PREVIOUS..$COMMIT" git log --no-merges --format='%H%x09%s' "$range" \ - | python3 "$TOOLS" notes "$GITHUB_REPOSITORY" "$VERSION" "$(date -u +%F)" > "$notes" + | python3 "$TOOLS" notes "$GITHUB_REPOSITORY" "$VERSION" "" > "$notes" echo "notes_file=$notes" >> "$GITHUB_OUTPUT" echo "--- release notes ---"; cat "$notes" - name: Ensure the settle PR + if: steps.state.outputs.state == 'needs-settle-pr' id: pr shell: bash env: @@ -256,10 +352,16 @@ runs: python3 - "$prs" "$VERSION" "$title" > "$RUNNER_TEMP/settle-prs.tsv" <<'PY' import json, re, sys + def normalized(login): + # `gh pr list --json author` reports an app as "app/"; PR_AUTHOR + # and every other check in this repo use "[bot]". Compare one form. + login = login or "" + return login[4:] + "[bot]" if login.startswith("app/") else login + for pr in json.load(open(sys.argv[1])): marker = re.search(r"", pr["body"]) title_ok = "yes" if pr.get("title") == sys.argv[3] else "no" - print(pr["number"], pr["author"]["login"], pr["url"], marker[1] if marker else "-", title_ok, sep="\t") + print(pr["number"], normalized(pr["author"]["login"]), pr["url"], marker[1] if marker else "-", title_ok, sep="\t") PY pr=""; old_marker=""; old_title_ok="" while IFS=$'\t' read -r number author url marker title_ok; do @@ -286,12 +388,16 @@ runs: fi changed=true fi - stage="committing the changelog on $head (local only; nothing pushed, a re-run starts over from the remote)" - what="$(python3 "$TOOLS" changelog-insert "$CHANGELOG" "$VERSION" "$NOTES")" - if ! git diff --quiet -- "$CHANGELOG"; then - git add "$CHANGELOG" - git commit --quiet -m "chore(release): refresh settle v$VERSION" -m "Changelog entry $what for v$VERSION at $COMMIT." - changed=true + if [[ -n "$CHANGELOG" ]]; then + stage="committing the changelog on $head (local only; nothing pushed, a re-run starts over from the remote)" + what="$(python3 "$TOOLS" changelog-insert "$CHANGELOG" "$VERSION" "$NOTES")" + if ! git diff --quiet -- "$CHANGELOG"; then + git add "$CHANGELOG" + git commit --quiet -m "chore(release): refresh settle v$VERSION" -m "Changelog entry $what for v$VERSION at $COMMIT." + changed=true + fi + else + echo "::notice::changelog disabled" fi if [[ "$changed" == false && "$old_marker" == "$COMMIT" && "$old_title_ok" == yes ]]; then echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT" @@ -330,13 +436,19 @@ runs: fi stage="committing the settlement on a fresh $head (local only; nothing pushed)" git checkout --quiet --force -B "$head" "$COMMIT" - what="$(python3 "$TOOLS" changelog-insert "$CHANGELOG" "$VERSION" "$NOTES")" - if git diff --quiet -- "$CHANGELOG"; then - git commit --quiet --allow-empty -m "chore(release): settle v$VERSION" \ - -m "Changelog already current at $COMMIT; this PR records the settlement decision." + if [[ -n "$CHANGELOG" ]]; then + what="$(python3 "$TOOLS" changelog-insert "$CHANGELOG" "$VERSION" "$NOTES")" + if git diff --quiet -- "$CHANGELOG"; then + git commit --quiet --allow-empty -m "chore(release): settle v$VERSION" \ + -m "Changelog already current at $COMMIT; this PR records the settlement decision." + else + git add "$CHANGELOG" + git commit --quiet -m "chore(release): settle v$VERSION" -m "Changelog entry $what for v$VERSION at $COMMIT." + fi else - git add "$CHANGELOG" - git commit --quiet -m "chore(release): settle v$VERSION" -m "Changelog entry $what for v$VERSION at $COMMIT." + echo "::notice::changelog disabled" + git commit --quiet --allow-empty -m "chore(release): settle v$VERSION" \ + -m "Changelog disabled at $COMMIT; this PR records the settlement decision." fi stage="pushing a fresh $head" if ! git push --quiet origin "$head"; then From 32ce645d90e91cde461bcab9e3bd79d3589ee647 Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:14:23 +0800 Subject: [PATCH 03/10] feat(release-publish): verify a dispatch settlement and resume safely Adds settle_source=dispatch, which verifies the merged candidate PR's merge SHA, head, dual base, author and live release-branch tip, re-running that whole check inside the tagging step. Resumes a tag whose branch has moved on. Publishes the changelog section verbatim. --- .github/actions/release-publish/README.md | 58 +++++++-- .github/actions/release-publish/action.yml | 137 +++++++++++++++++++-- 2 files changed, 173 insertions(+), 22 deletions(-) diff --git a/.github/actions/release-publish/README.md b/.github/actions/release-publish/README.md index b282bfa..9d7c2dd 100644 --- a/.github/actions/release-publish/README.md +++ b/.github/actions/release-publish/README.md @@ -3,7 +3,7 @@ `uses: megaeth-labs/.github/.github/actions/release-publish@main` -Publish a settled release. Runs on a push to `release-vX.Y.Z` (the consumer passes the `commit` and `pr_number` that `release-route` resolved for the push). Re-verifies the merged `chore/release-settle-vX.Y.Z` PR through the API — merged, merge commit equals the pushed commit, head/base names, author, and the settled-SHA marker against the merge commit's parent — then creates the annotated tag `vX.Y.Z` at that commit and publishes the GitHub Release with the changelog section as notes. A tag already at the commit without a Release (a run that died between the two steps) is resumed: the tag step is skipped and the Release is still created. A tag at the commit with its Release already there is a retry of a finished run and exits 0 with a notice; a tag at any other commit refuses, because a release is published once. A version that is not newer than every other `vX.Y.Z` tag (the rule `release-route` applies) is refused before the tag, and the Release is marked latest only when it is newer than the current latest Release. A tag push or Release create that loses a race to a concurrent run of the same publish keeps the winner's, if it is at this commit. 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. +Publish a settled release. By default, runs on a push to `release-vX.Y.Z` (the consumer passes the `commit` and `pr_number` that `release-route` resolved for the push). Re-verifies the merged `chore/release-settle-vX.Y.Z` PR through the API — merged, merge commit equals the pushed commit, head/base names, author, and the settled-SHA marker against the merge commit's parent. With `settle_source: dispatch`, verifies the merged candidate PR's merge SHA, head, base and author, and that `release_branch` still points at `commit` for a fresh publish, or contains `commit` when resuming an existing tag; repeats this state check immediately before tagging. Then creates the annotated tag `vX.Y.Z` at that commit and publishes the GitHub Release with the changelog section as notes. A tag already at the commit without a Release (a run that died between the two steps) is resumed: the tag step is skipped and the Release is still created. Re-run the push-triggered workflow for the settle-PR shape, or dispatch for the direct shape. A tag at the commit with its Release already there is a retry of a finished run and exits 0 with a notice; a tag at any other commit refuses, because a release is published once. A version that is not newer than every other `vX.Y.Z` tag (the rule `release-route` applies) is refused before the tag, and the Release is marked latest only when it is newer than the current latest Release. A tag push or Release create that loses a race to a concurrent run of the same publish keeps the winner's, if it is at this commit. 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. Family: [Release pipeline](../RELEASE.md). @@ -13,14 +13,16 @@ Family: [Release pipeline](../RELEASE.md). | Input | Required | Default | Description | |---|---|---|---| -| `token` | yes | | mega-gutenberg installation token minted in an `environment: release` job; gutenberg is the bypass actor on the `v*` tag ruleset, and a Release it creates fires `release: published` workflows. | +| `token` | yes | | mega-gutenberg installation token from repository-scope `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY`; gutenberg is the bypass actor on the `v*` tag ruleset, and a Release it creates fires `release: published` workflows. The one-reviewer `publish` environment gates only tagging, Release creation and artifact upload. | | `version_file` | yes | | File holding the version (VERSION, Cargo.toml, pyproject.toml, package.json); the same value as in release-candidate. | | `version_pattern` | no | `plain` | How the version is stored in `version_file`: plain, toml or json. | | `changelog_file` | no | `CHANGELOG.md` | Changelog the entry is written to (Keep-a-Changelog style, newest first). | | `release_branch_prefix` | no | `release-v` | Release branch name prefix; the branch is `X.Y.Z`. | -| `commit` | yes | | The pushed commit to publish (full SHA), as resolved by `release-route`; it must be the settle PR's merge commit. | -| `pr_number` | yes | | Number of the merged settle PR for `commit`, as resolved by `release-route`. | -| `pr_author` | no | `mega-gutenberg[bot]` | Login the settle PR must have been opened by — the app identity `release-settle` runs under. Stops a hand-made `chore/release-settle-*` branch from reaching the tag step. Empty disables the check. | +| `settle_source` | no | `pr` | Settlement source: pr verifies a merged settle PR; dispatch verifies a merged candidate PR and the current release-branch tip. | +| `release_branch` | no | | Release branch that must equal X.Y.Z; for dispatch its tip must equal commit for a fresh publish, or contain commit when resuming an existing tag. | +| `commit` | yes | | The commit to publish (full SHA); it must be the settle PR's merge commit for pr, or the candidate PR's merge commit for dispatch. | +| `pr_number` | yes | | Number of the merged settle PR for pr, or the merged candidate PR for dispatch. | +| `pr_author` | no | `mega-gutenberg[bot]` | Login the settle or candidate PR must have been opened by — the app identity `release-settle` runs under. Stops a hand-made `chore/release-settle-*` branch from reaching the tag step. Empty disables the check only for pr. | | `git_user_name` | no | `mega-gutenberg[bot]` | Committer identity for the commits this action makes. | | `git_user_email` | no | `348780249+mega-gutenberg[bot]@users.noreply.github.com` | Committer email for the commits this action makes. | @@ -48,8 +50,18 @@ Family: [Release pipeline](../RELEASE.md). - `publish needs a full 40-hex commit and a numeric pr_number (the release-route outputs)` +- `settle_source must be pr or dispatch, got` +- `candidate PR #$PR_NUMBER is not merged` - `settle PR #$PR_NUMBER is not merged; publish only follows a merged settle PR` +- `candidate PR #$PR_NUMBER merge_commit_sha is` - `settle PR #$PR_NUMBER merged as` +- `release_branch is required for dispatch settlement` +- `release_branch is` +- `candidate PR #$PR_NUMBER head ref is` +- `candidate PR #$PR_NUMBER base is` +- `candidate PR #$PR_NUMBER author is` +- `tag v$version already exists (at $(git rev-parse` +- `release branch` - `head branch` - `settle PR #$PR_NUMBER was opened by` - `settle PR for v$version merged into` @@ -57,6 +69,7 @@ Family: [Release pipeline](../RELEASE.md). - `release branch drifted: settle PR settled $settled but the branch tip at merge was $parent. Re-run release-settle on the current tip.` - `tag $tag already exists (at $(git rev-parse` - `$FILE at $MERGE_SHA says $actual, expected $version` +- `changelog section for v$version at $MERGE_SHA disagrees with regenerated notes; let the settle refresh land first, then publish.` - `could not push $TAG; the remote tag is at $(git rev-parse` - `cannot read the latest Release to decide whether $TAG is the latest; re-run to resume` - `could not create the Release for $TAG` @@ -64,8 +77,8 @@ Family: [Release pipeline](../RELEASE.md). ## Example -PR mode, as in `workflow-templates/release-publish.yml` (triggered by the -settle PR closing; the job gate checks it merged and was opened by the app): +PR mode uses `settle_source: pr` (the default) and is triggered by the +settle PR's merge push to the release branch: ```yaml - uses: megaeth-labs/.github/.github/actions/release-publish@main @@ -74,10 +87,26 @@ settle PR closing; the job gate checks it merged and was opened by the app): version_file: Cargo.toml version_pattern: toml changelog_file: CHANGELOG.md + commit: ${{ steps.route.outputs.commit }} + pr_number: ${{ steps.route.outputs.pr_number }} ``` -In direct mode `release-settle` calls this action itself with `commit` and -`version`; a consumer never does. +For direct settlement, pass `settle_source: dispatch`, `release_branch`, +`commit` and the candidate PR's `pr_number`. The action verifies through the +GitHub API that the candidate is merged with `merge_commit_sha == commit`, +its head is `chore/release-candidate-`, its base is the default +branch or `release_branch`, its author equals `pr_author`, and the current +release-branch tip equals `commit` for a fresh publish (no tag yet). When the +tag already exists at `commit`, the tip may equal it or be a descendant: +`git merge-base --is-ancestor "$MERGE_SHA" "$tip"` must succeed. This lets a +dispatch resume a tag without a Release after the branch has advanced, while +still refusing a fresh publish from a moved branch. The version comes from `version_file` at +`commit`; `release_branch` must equal ``. +This path skips the settle marker and first-parent checks and +repeats the entire state check inside the tag step immediately before tagging, +so branch drift during an environment approval is refused for a fresh publish. +Both refusal messages state the fresh-publish equality rule and the existing-tag +ancestry rule. ## Notes @@ -85,9 +114,18 @@ In direct mode `release-settle` calls this action itself with `commit` and no-ops if it is already at this commit) at the merge commit in PR mode — refusing if the merge's first parent is not the settled SHA recorded in the PR, or the PR was not opened by `pr_author` — or at - `commit` in explicit mode; then publishes the GitHub Release with the + `commit` in dispatch mode; then publishes the GitHub Release with the changelog section as notes, marked latest only when it is newer than the current latest Release. +- If the tag exists at `commit` but its Release does not, both sources skip + tagging and resume Release creation after their verification passes. Resume + the settle-PR shape by re-running the push-triggered workflow; resume the + direct shape by dispatching again with the same inputs. A tag elsewhere is + always refused; a tag at `commit` with a Release already present is a no-op. +- The Release body is the changelog section extracted from the tagged tree, + verbatim, with no injected date. With an empty `changelog_file`, notes are + generated from non-merge commits since the previous release tag (or all + commits for the first release), with an undated heading. - Refuses a version that is not newer than every other `vX.Y.Z` tag (the rule `release-route` applies), in the guard and again right before the tag push; tags that are not `vX.Y.Z` are ignored. diff --git a/.github/actions/release-publish/action.yml b/.github/actions/release-publish/action.yml index a7763d9..6439b18 100644 --- a/.github/actions/release-publish/action.yml +++ b/.github/actions/release-publish/action.yml @@ -1,14 +1,19 @@ name: Release Publish description: >- - Publish a settled release. Runs on a push to `release-vX.Y.Z` (the consumer + Publish a settled release. By default, runs on a push to `release-vX.Y.Z` (the consumer passes the `commit` and `pr_number` that `release-route` resolved for the push). Re-verifies the merged `chore/release-settle-vX.Y.Z` PR through the API — merged, merge commit equals the pushed commit, head/base names, author, - and the settled-SHA marker against the merge commit's parent — then creates + and the settled-SHA marker against the merge commit's parent. With + `settle_source: dispatch`, verifies the merged candidate PR's merge SHA, + head, base and author, and that `release_branch` still points at `commit` + for a fresh publish, or contains `commit` when resuming an existing tag; + repeats this state check immediately before tagging. Then creates the annotated tag `vX.Y.Z` at that commit and publishes the GitHub Release with the changelog section as notes. A tag already at the commit without a Release (a run that died between the two steps) is resumed: the tag step is - skipped and the Release is still created. A tag at the commit with its + skipped and the Release is still created. Re-run the push-triggered workflow + for the settle-PR shape, or dispatch for the direct shape. A tag at the commit with its Release already there is a retry of a finished run and exits 0 with a notice; a tag at any other commit refuses, because a release is published once. A version that is not newer than every other `vX.Y.Z` tag (the rule @@ -25,9 +30,11 @@ description: >- inputs: token: description: >- - mega-gutenberg installation token minted in an `environment: release` - job; gutenberg is the bypass actor on the `v*` tag ruleset, and a Release - it creates fires `release: published` workflows. + mega-gutenberg installation token from repository-scope + `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY`; gutenberg is the + bypass actor on the `v*` tag ruleset, and a Release it creates fires + `release: published` workflows. The one-reviewer `publish` environment + gates only tagging, Release creation and artifact upload. required: true version_file: description: "File holding the version (VERSION, Cargo.toml, pyproject.toml, package.json); the same value as in release-candidate." @@ -44,17 +51,25 @@ inputs: description: "Release branch name prefix; the branch is `X.Y.Z`." required: false default: release-v + settle_source: + description: "Settlement source: pr verifies a merged settle PR; dispatch verifies a merged candidate PR and the current release-branch tip." + required: false + default: pr + release_branch: + description: "Release branch that must equal X.Y.Z; for dispatch its tip must equal commit for a fresh publish, or contain commit when resuming an existing tag." + required: false + default: "" commit: - description: "The pushed commit to publish (full SHA), as resolved by `release-route`; it must be the settle PR's merge commit." + description: "The commit to publish (full SHA); it must be the settle PR's merge commit for pr, or the candidate PR's merge commit for dispatch." required: true pr_number: - description: "Number of the merged settle PR for `commit`, as resolved by `release-route`." + description: "Number of the merged settle PR for pr, or the merged candidate PR for dispatch." required: true pr_author: description: >- - Login the settle PR must have been opened by — the app identity + Login the settle or candidate PR must have been opened by — the app identity `release-settle` runs under. Stops a hand-made `chore/release-settle-*` - branch from reaching the tag step. Empty disables the check. + branch from reaching the tag step. Empty disables the check only for pr. required: false default: "mega-gutenberg[bot]" git_user_name: @@ -99,6 +114,8 @@ runs: MERGE_SHA: ${{ inputs.commit }} PR_NUMBER: ${{ inputs.pr_number }} EXPECTED_AUTHOR: ${{ inputs.pr_author }} + SETTLE_SOURCE: ${{ inputs.settle_source }} + RELEASE_BRANCH: ${{ inputs.release_branch }} PREFIX: ${{ inputs.release_branch_prefix }} FILE: ${{ inputs.version_file }} PATTERN: ${{ inputs.version_pattern }} @@ -110,9 +127,13 @@ runs: if [[ ! "$MERGE_SHA" =~ ^[0-9a-f]{40}$ || ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then echo "::error::publish needs a full 40-hex commit and a numeric pr_number (the release-route outputs)"; exit 1 fi + if [[ "$SETTLE_SOURCE" != pr && "$SETTLE_SOURCE" != dispatch ]]; then + echo "::error::settle_source must be pr or dispatch, got '$SETTLE_SOURCE'"; exit 1 + fi # Nothing about the settle PR is taken from the triggering event: the # push only says which commit landed. Everything below is read back # from the API and the repository. + verify_state() { pr="$RUNNER_TEMP/settle-pr.json" gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" > "$pr" pr_field() { # @@ -129,11 +150,52 @@ runs: pr_author="$(pr_field user.login)" pr_body="$(pr_field body)" if [[ -z "$merged_at" ]]; then + if [[ "$SETTLE_SOURCE" == dispatch ]]; then + echo "::error::candidate PR #$PR_NUMBER is not merged"; exit 1 + fi echo "::error::settle PR #$PR_NUMBER is not merged; publish only follows a merged settle PR"; exit 1 fi if [[ "$pr_merge_sha" != "$MERGE_SHA" ]]; then + if [[ "$SETTLE_SOURCE" == dispatch ]]; then + echo "::error::candidate PR #$PR_NUMBER merge_commit_sha is '$pr_merge_sha', not the requested commit $MERGE_SHA"; exit 1 + fi echo "::error::settle PR #$PR_NUMBER merged as '$pr_merge_sha', not the pushed commit $MERGE_SHA"; exit 1 fi + if [[ "$SETTLE_SOURCE" == dispatch ]]; then + if [[ -z "$RELEASE_BRANCH" ]]; then + echo "::error::release_branch is required for dispatch settlement"; exit 1 + fi + git fetch --quiet --tags origin "$MERGE_SHA" + version="$(git show "$MERGE_SHA:$FILE" | python3 "$TOOLS" read-file /dev/stdin "$PATTERN")" + version="$(python3 "$TOOLS" normalize "$version")" + if [[ "$RELEASE_BRANCH" != "${PREFIX}${version}" ]]; then + echo "::error::release_branch is '$RELEASE_BRANCH', expected '${PREFIX}${version}' for dispatch settlement"; exit 1 + fi + if [[ "$head_ref" != "chore/release-candidate-$version" ]]; then + echo "::error::candidate PR #$PR_NUMBER head ref is '$head_ref', expected chore/release-candidate-$version"; exit 1 + fi + default_branch="$(gh api "repos/$GITHUB_REPOSITORY" --jq .default_branch)" + if [[ "$base_ref" != "$default_branch" && "$base_ref" != "$RELEASE_BRANCH" ]]; then + echo "::error::candidate PR #$PR_NUMBER base is '$base_ref', expected '$default_branch' or '$RELEASE_BRANCH'"; exit 1 + fi + if [[ "$pr_author" != "$EXPECTED_AUTHOR" ]]; then + echo "::error::candidate PR #$PR_NUMBER author is '$pr_author', expected '$EXPECTED_AUTHOR'"; exit 1 + fi + tip="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/$RELEASE_BRANCH" --jq .object.sha)" + if git ls-remote --exit-code --tags origin "refs/tags/v$version" >/dev/null; then + git fetch --quiet --force origin "refs/tags/v$version:refs/tags/v$version" + if [[ "$(git rev-parse "v$version^{commit}")" != "$MERGE_SHA" ]]; then + echo "::error::tag v$version already exists (at $(git rev-parse "v$version^{commit}")), not at $MERGE_SHA; a release is published once"; exit 1 + fi + git fetch --quiet origin "$tip" + if ! git merge-base --is-ancestor "$MERGE_SHA" "$tip"; then + echo "::error::release branch '$RELEASE_BRANCH' tip is '$tip'; resuming an existing tag requires $MERGE_SHA to be an ancestor of the tip (equality is allowed); a fresh publish without a tag requires exact tip equality"; exit 1 + fi + elif [[ "$tip" != "$MERGE_SHA" ]]; then + echo "::error::release branch '$RELEASE_BRANCH' tip is '$tip'; a fresh publish without a tag requires exact tip equality with $MERGE_SHA; resuming an existing tag instead requires that commit to be an ancestor of the tip (equality is allowed)"; exit 1 + fi + return + fi if [[ "$head_ref" != chore/release-settle-v* ]]; then echo "::error::head branch '$head_ref' is not a settle PR"; exit 1 fi @@ -157,6 +219,10 @@ runs: if [[ "$parent" != "$settled" ]]; then echo "::error::release branch drifted: settle PR settled $settled but the branch tip at merge was $parent. Re-run release-settle on the current tip."; exit 1 fi + } + verify_state + # Keep the same verifier for a fresh API read in the tag step. + declare -f verify_state > "$RUNNER_TEMP/release-publish-verify-state.sh" # A release is published once, at this commit. A tag already here is # an earlier run of this same publish: without a Release it died # between the two steps and is resumed; with one it finished, and this @@ -175,6 +241,7 @@ runs: published=true else release_url="" + echo "::notice::tag $tag is already at $MERGE_SHA without a Release; resuming. Re-run the push-triggered workflow for the settle-PR shape, or dispatch for the direct shape." fi fi actual="$(git show "$MERGE_SHA:$FILE" | python3 "$TOOLS" read-file /dev/stdin "$PATTERN")" @@ -193,8 +260,42 @@ runs: echo "order check passed: v$version is newer than the latest tag ${latest:-}" fi notes="$RUNNER_TEMP/release-notes-v$version.md" - git show "$MERGE_SHA:$CHANGELOG" > "$RUNNER_TEMP/changelog-at-merge.md" - python3 "$TOOLS" changelog-extract "$RUNNER_TEMP/changelog-at-merge.md" "$version" > "$notes" + if [[ -n "$CHANGELOG" ]]; then + git show "$MERGE_SHA:$CHANGELOG" > "$RUNNER_TEMP/changelog-at-merge.md" + python3 "$TOOLS" changelog-extract "$RUNNER_TEMP/changelog-at-merge.md" "$version" > "$notes" + else + previous="$(git tag --list 'v*' | python3 "$TOOLS" latest-tag --exclude "$version")" + range="$MERGE_SHA" + [[ -n "$previous" ]] && range="$previous..$MERGE_SHA" + git log --no-merges --format='%H%x09%s' "$range" \ + | python3 "$TOOLS" notes "$GITHUB_REPOSITORY" "$version" "" > "$notes" + fi + if [[ "$SETTLE_SOURCE" == dispatch && "$published" != true ]]; then + section_matches=true + if [[ -n "$CHANGELOG" ]]; then + previous="$(git tag --list 'v*' | python3 "$TOOLS" latest-tag --exclude "$version")" + range="$MERGE_SHA" + [[ -n "$previous" ]] && range="$previous..$MERGE_SHA" + regenerated="$RUNNER_TEMP/release-regenerated-v$version.md" + committed="$RUNNER_TEMP/release-committed-v$version.md" + git log --no-merges --format='%H%x09%s' "$range" \ + | python3 "$TOOLS" notes "$GITHUB_REPOSITORY" "$version" "" > "$regenerated" + python3 "$TOOLS" changelog-extract "$RUNNER_TEMP/changelog-at-merge.md" "$version" \ + --include-heading > "$committed" + section_matches=false + if [[ "$(sed 's/[[:space:]]*$//' "$regenerated")" == "$(sed 's/[[:space:]]*$//' "$committed")" ]]; then + section_matches=true + fi + fi + decision="$(python3 "$TOOLS" settle-state --version "$version" \ + --branch-tip "$MERGE_SHA" --candidate-merge-sha "$MERGE_SHA" \ + --candidate-is-ancestor true --open-settle-pr false \ + --published-tag-exists false --release-exists false \ + --section-matches "$section_matches")" + if [[ "${decision%%$'\n'*}" != clean ]]; then + echo "::error::changelog section for v$version at $MERGE_SHA disagrees with regenerated notes; let the settle refresh land first, then publish."; exit 1 + fi + fi { echo "version=$version" echo "tag=$tag" @@ -218,8 +319,16 @@ runs: env: AUTH_WRAPPER: ${{ github.action_path }}/../git-auth/with-github-auth.sh GITHUB_AUTH_TOKEN: ${{ inputs.token }} + GH_TOKEN: ${{ inputs.token }} TAG: ${{ steps.guard.outputs.tag }} MERGE_SHA: ${{ steps.guard.outputs.merge_sha }} + PR_NUMBER: ${{ inputs.pr_number }} + EXPECTED_AUTHOR: ${{ inputs.pr_author }} + SETTLE_SOURCE: ${{ inputs.settle_source }} + RELEASE_BRANCH: ${{ inputs.release_branch }} + PREFIX: ${{ inputs.release_branch_prefix }} + FILE: ${{ inputs.version_file }} + PATTERN: ${{ inputs.version_pattern }} TOOLS: ${{ github.action_path }}/../release-tools/release_tools.py run: | "$AUTH_WRAPPER" bash --noprofile --norc -e -o pipefail <<'GITHUB_AUTH_COMMAND' @@ -231,6 +340,10 @@ runs: if ! refusal="$(python3 "$TOOLS" check-order "$TAG" "$latest")"; then echo "::error title=Refusing to publish an older version::not tagging $MERGE_SHA: $refusal (tagged since the guard ran)"; exit 1 fi + if [[ "$SETTLE_SOURCE" == dispatch ]]; then + source "$RUNNER_TEMP/release-publish-verify-state.sh" + verify_state + fi git tag -a "$TAG" "$MERGE_SHA" -m "Release $TAG" if git push --quiet origin "refs/tags/$TAG"; then echo "tagged $MERGE_SHA as $TAG" From e429420b8347690b27805162b9088d75238e3b8f Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:14:23 +0800 Subject: [PATCH 04/10] feat(release-route): collect settlement facts for a dispatch A versioned dispatch now gathers the merged candidate PR (normalising an app login from app/ to [bot]), the ancestry of its merge commit, whether a settle PR is open, the published tag SHA and whether a Release exists, and emits pr_number. --- .github/actions/release-route/README.md | 30 ++++++++++++- .github/actions/release-route/action.yml | 55 +++++++++++++++++++++--- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/.github/actions/release-route/README.md b/.github/actions/release-route/README.md index 89e27b4..f1348d5 100644 --- a/.github/actions/release-route/README.md +++ b/.github/actions/release-route/README.md @@ -27,12 +27,13 @@ Family: [Release pipeline](../RELEASE.md). | Output | Description | |---|---| -| `action` | Next action: cut, refresh, publish or skip. | +| `action` | Next action: cut, refresh, publish or skip; versioned settle dispatch returns publish, refresh or skip. | | `reason` | Why this action was selected. | +| `settle_source` | Publication source: dispatch for a versioned settle dispatch routed to publish; empty otherwise. | | `version` | Normalized release version, when routing one push. | | `release_branch` | Release branch for the pushed version. | | `commit` | Pushed commit for cut, refresh or publish. | -| `pr_number` | Merged release PR number, when matched. | +| `pr_number` | Merged release PR number, or candidate PR number for a versioned settle dispatch, when provided. | | `targets` | Compact JSON array of refresh targets. | @@ -66,6 +67,31 @@ Family: [Release pipeline](../RELEASE.md). The calling job needs `contents: read` and `pull-requests: read`. +For `kind: settle` on `workflow_dispatch` with a `version`, `action` is +`publish`, `refresh` or `skip`: publish or resume publication, refresh the +settle PR, or take no action, respectively. `reason` explains the decision. +`settle_source` is `dispatch` when the decision is `publish` and empty +otherwise; `pr_number` is the selected merged candidate PR's `.number`, +passed as `candidate_pr_number` to `route()` and written to `GITHUB_OUTPUT` +by the decision output loop. +The action passes the dispatch context to `route()` unchanged and exposes +its decision without remapping it. Candidate pushes may still return `cut`. + +For a versioned settle dispatch, each enumerated release branch includes +`candidate_merge_sha` from the merged candidate PR with head +`chore/release-candidate-` (no leading `v`) targeting the default +branch or that release branch, `candidate_is_ancestor` from a Git ancestry +check against its tip, `open_settle_pr` from the open settle PR lookup, and +`published_tag_sha` from the peeled release tag (or the lightweight tag). +All four keys are always present: missing SHAs and ancestry without a candidate +are empty strings, and no open settle PR is `false`. Dispatches without a +version leave these facts empty or false. + +Each dispatch branch also includes `release_exists`, queried with +`gh release view v` when its tag exists and otherwise `false`. +Together with the tag facts, this lets an already published version route +to `skip` instead of `publish`. + A push to a `release_branch_prefix` branch whose suffix is not `X.Y.Z` (for example `release-vnext`) routes to `skip` with a reason naming the branch; a manual dispatch leaves such branches out of `targets`. Input the route cannot diff --git a/.github/actions/release-route/action.yml b/.github/actions/release-route/action.yml index 674cbb8..e1b28f3 100644 --- a/.github/actions/release-route/action.yml +++ b/.github/actions/release-route/action.yml @@ -29,11 +29,14 @@ inputs: outputs: action: - description: "Next action: cut, refresh, publish or skip." + description: "Next action: cut, refresh, publish or skip; versioned settle dispatch returns publish, refresh or skip." value: ${{ steps.route.outputs.action }} reason: description: "Why this action was selected." value: ${{ steps.route.outputs.reason }} + settle_source: + description: "Publication source: dispatch for a versioned settle dispatch routed to publish; empty otherwise." + value: ${{ steps.route.outputs.settle_source }} version: description: "Normalized release version, when routing one push." value: ${{ steps.route.outputs.version }} @@ -44,7 +47,7 @@ outputs: description: "Pushed commit for cut, refresh or publish." value: ${{ steps.route.outputs.commit }} pr_number: - description: "Merged release PR number, when matched." + description: "Merged release PR number, or candidate PR number for a versioned settle dispatch, when provided." value: ${{ steps.route.outputs.pr_number }} targets: description: "Compact JSON array of refresh targets." @@ -138,7 +141,7 @@ runs: jq -ce 'if type != "array" then error("pr list must return an array") else map({number, merge_commit_sha: .mergeCommit.oid, merged_at: .mergedAt, head: {ref: .headRefName}, base: {ref: .baseRefName}, - user: {login: .author.login}, body}) end')" + user: {login: (if ((.author.login // "") | startswith("app/")) then (.author.login[4:] + "[bot]") else (.author.login // "") end)}, body}) end')" fi fi elif [[ "$KIND" == settle && "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then @@ -150,11 +153,48 @@ runs: echo "::notice title=Release route::skipping $branch: not $PREFIX"; continue fi git fetch --quiet origin "$tip" - tagged=false; actual="" - if has_tag "v$version"; then tagged=true; else read_version "$tip" "$branch"; fi + tagged=false; branch_release_exists=false; actual="" + if has_tag "v$version"; then + tagged=true + if gh release view "v$version" --json tagName >/dev/null 2>&1; then branch_release_exists=true; fi + else + read_version "$tip" "$branch" + fi + candidate_merge_sha=""; candidate_is_ancestor='""'; candidate_pr_number="" + open_settle_pr=false; published_tag_sha="" + if [[ -n "$DISPATCH_VERSION" ]]; then + candidate_pr="$(gh pr list --state merged \ + --head "chore/release-candidate-$version" \ + --json number,mergeCommit,headRefName,baseRefName,author | + jq -r --arg default "$default" --arg branch "$branch" \ + '[.[] | select(.baseRefName == $default or .baseRefName == $branch)][0] // {}')" + candidate_merge_sha="$(jq -r '.mergeCommit.oid // ""' <<< "$candidate_pr")" + candidate_pr_number="$(jq -r '.number // ""' <<< "$candidate_pr")" + if [[ -n "$candidate_merge_sha" ]]; then + candidate_is_ancestor=false + if git merge-base --is-ancestor "$candidate_merge_sha" "$tip"; then + candidate_is_ancestor=true + fi + fi + open_settle_pr="$(gh pr list --state open \ + --head "chore/release-settle-v$version" --json number | + jq -c 'length > 0')" + if [[ "$tagged" == true ]]; then + published_tag_sha="$(git ls-remote origin "refs/tags/v$version^{}" | cut -f1)" + if [[ -z "$published_tag_sha" ]]; then + published_tag_sha="$(git ls-remote origin "refs/tags/v$version" | cut -f1)" + fi + fi + fi branches="$(jq -c --arg name "$branch" --arg tip "$tip" --arg actual "$actual" \ - --argjson tagged "$tagged" \ - '. + [{name: $name, tip: $tip, version_at_tip: (if $actual == "" then null else $actual end), tag_exists: $tagged}]' <<< "$branches")" + --argjson tagged "$tagged" --argjson release_exists "$branch_release_exists" \ + --arg candidate_pr_number "$candidate_pr_number" \ + --arg candidate_merge_sha "$candidate_merge_sha" --argjson candidate_is_ancestor "$candidate_is_ancestor" \ + --argjson open_settle_pr "$open_settle_pr" --arg published_tag_sha "$published_tag_sha" \ + '. + [{name: $name, tip: $tip, version_at_tip: (if $actual == "" then null else $actual end), tag_exists: $tagged, + release_exists: $release_exists, candidate_pr_number: $candidate_pr_number, + candidate_merge_sha: $candidate_merge_sha, candidate_is_ancestor: $candidate_is_ancestor, + open_settle_pr: $open_settle_pr, published_tag_sha: $published_tag_sha}]' <<< "$branches")" done <<< "$heads" fi if ! decision="$(jq -n --arg kind "$KIND" --arg event "$GITHUB_EVENT_NAME" \ @@ -174,6 +214,7 @@ runs: exit 1 fi # Multiline output framing also keeps API-supplied reasons from becoming output keys. + # On dispatch, route() maps candidate_pr_number to pr_number for GITHUB_OUTPUT below. while read -r key; do delimiter="route_$(openssl rand -hex 16)" { From 946804df2ff389360237b1c01f8d15b33b2f149a Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:14:23 +0800 Subject: [PATCH 05/10] feat(release-candidate): refresh an open candidate without rewriting it Adds stage: refresh, which merges the PR's own base and re-drafts the entry as a fast-forward push, plus the workflow job that refreshes trunk candidates only. Documents that a refresh clears approvals. --- .github/actions/release-candidate/README.md | 38 ++++-- .github/actions/release-candidate/action.yml | 125 +++++++++++++++---- workflow-templates/release-candidate.yml | 90 +++++++++++-- 3 files changed, 214 insertions(+), 39 deletions(-) diff --git a/.github/actions/release-candidate/README.md b/.github/actions/release-candidate/README.md index 5f3d7ce..4937aa2 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 on the push that merged that PR (the consumer passes the `commit` and `pr_number` outputs of `release-route`), re-reads the PR through the API, refuses anything but a merged, gutenberg-authored candidate into the default branch, and creates `release-vX.Y.Z` at the merge commit (a retry that finds it already there is a no-op; at any other commit it refuses). 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, carries the default branch's release workflows onto it, and points the candidate PR at it; that PR merges into the release branch, so `cut` never runs for it. Run as a step in a job the consumer owns, in `environment: release`; 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 undated changelog entry from the commits since the previous tag, syncs the previous release's entry from its tag, and opens a `chore/release-candidate-X.Y.Z` PR. `stage: refresh` merges an open candidate PR's own base into its head and re-drafts the changelog without rewriting history; any push requires renewed approval. `stage: cut`, run on the push that merged that PR (the consumer passes the `commit` and `pr_number` outputs of `release-route`), re-reads the PR through the API, refuses anything but a merged, gutenberg-authored candidate into the default branch, and creates `release-vX.Y.Z` at the merge commit (a retry that finds it already there is a no-op; at any other commit it refuses). No tag is created at any 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, carries the default branch's release workflows onto it, and points the candidate PR at it; that PR merges into the release branch, so `cut` never runs for it. Run as a step in a job the consumer owns; the repository-scope `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY` provide the app credentials. The one-reviewer `publish` environment gates only tagging, Release creation and artifact upload. 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). @@ -13,13 +13,13 @@ Family: [Release pipeline](../RELEASE.md). | Input | Required | Default | Description | |---|---|---|---| -| `stage` | yes | | `propose` (on workflow_dispatch) or `cut` (on the candidate PR merging). | -| `token` | yes | | mega-gutenberg installation token (contents, pull-requests, workflows write) minted in an `environment: release` job; it pushes the candidate branch, opens the PR (so CI runs) and creates the release branch. | +| `stage` | yes | | `propose` (on workflow_dispatch), `refresh` (an open candidate), or `cut` (on the candidate PR merging). | +| `token` | yes | | mega-gutenberg installation token (contents, pull-requests, workflows write) from repository-scope `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY`; it pushes the candidate branch, opens the PR (so CI runs) and creates the release branch. The one-reviewer `publish` environment gates only tagging, Release creation and artifact upload. | | `dependency_token` | no | | Read-only token for bump_command's private dependencies. Required when bump_command is set; it never falls back to token, so the command does not run with the release token. | | `commit` | no | | The merge commit of the candidate PR (the `commit` output of `release-route`). Required for `cut`; the release branch is created at it. | | `pr_number` | no | | The candidate PR number (the `pr_number` output of `release-route`). Required for `cut`; the PR is re-read through the API, not trusted from the event. | | `release_workflows` | no | `.github/workflows/release-settle.yml .github/workflows/on-release.yml` | Newline- or space-separated workflow paths `propose` with `base_commit` copies from the default branch onto the old tree (a path absent on the default branch is skipped), and it deletes `.github/workflows/release-publish.yml`. Workflows run from the pushed ref's tree, so a branch cut at an older commit would otherwise run pre-migration release workflows. | -| `version` | no | | Version to release, X.Y.Z or vX.Y.Z. Required for `propose`. | +| `version` | no | | Version to release, X.Y.Z or vX.Y.Z. Required for `propose` and `refresh`. | | `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. | @@ -27,7 +27,7 @@ Family: [Release pipeline](../RELEASE.md). | `release_branch_prefix` | no | `release-v` | Release branch name prefix; the branch is `X.Y.Z`. | | `changelog_file` | no | `CHANGELOG.md` | Changelog to draft into (Keep-a-Changelog style, newest first). Empty disables changelog handling. | | `pr_labels` | no | | Comma-separated labels for the candidate PR. | -| `pr_author` | no | `mega-gutenberg[bot]` | Login the candidate PR must have been opened by for `cut` to proceed — the app identity `propose` runs under. Stops a hand-made `chore/release-candidate-*` branch from driving the cut. Empty disables the check. | +| `pr_author` | no | `mega-gutenberg[bot]` | Login the candidate PR must have been opened by for `cut` or `refresh` to proceed — the app identity `propose` runs under. Stops a hand-made `chore/release-candidate-*` branch from driving the cut. Empty disables the check for `cut`; `refresh` always requires a bot PR and uses mega-gutenberg[bot] when empty. | | `git_user_name` | no | `mega-gutenberg[bot]` | Committer identity for the bump commit. | | `git_user_email` | no | `348780249+mega-gutenberg[bot]@users.noreply.github.com` | Committer email for the commits this action makes. | @@ -38,7 +38,7 @@ Family: [Release pipeline](../RELEASE.md). | Output | Description | |---|---| | `version` | Normalised X.Y.Z. | -| `pr_url` | `propose`: URL of the candidate PR. | +| `pr_url` | `propose` or `refresh`: URL of the candidate PR. | | `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. | @@ -53,7 +53,9 @@ Family: [Release pipeline](../RELEASE.md). 1. Carry the release workflows (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 != '' && steps.bump.outputs.skipped != 'true'`)* -1. Draft changelog *(only if `inputs.stage == 'propose' && inputs.changelog_file != '' && steps.bump.outputs.skipped != 'true'`)* +1. Prepare candidate refresh *(only if `inputs.stage == 'refresh'`)* +1. Draft changelog *(only if `inputs.changelog_file != '' && (inputs.stage == 'refresh' || (inputs.stage == 'propose' && steps.bump.outputs.skipped != 'true'))`)* +1. Push candidate refresh *(only if `inputs.stage == 'refresh'`)* 1. Open candidate PR *(only if `inputs.stage == 'propose'`)* 1. Cut release branch *(only if `inputs.stage == 'cut'`)* @@ -62,7 +64,7 @@ Family: [Release pipeline](../RELEASE.md). - `cut needs the pr_number input (the release-route output)` -- `stage must be propose or cut, got` +- `stage must be propose, refresh or cut, got` - `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` @@ -73,6 +75,9 @@ Family: [Release pipeline](../RELEASE.md). - `could not create $BRANCH at $checkout (it is now at` - `bump_command needs a separate dependency_token, so the command never runs with the release token` - `after bump_command, $FILE reads $actual, expected $NEW_VERSION` +- `refresh needs exactly one open candidate PR for v$VERSION` +- `refresh requires an open, app-authored candidate PR from this repository ($author)` +- `tag v$VERSION already exists; refusing to refresh a published version` - `could not close the open candidate PR #$number ($url)` - `could not delete $head, and no open PR owns it` - `could not push $head` @@ -96,9 +101,9 @@ both from `workflow-templates/release-candidate.yml`: ```yaml - uses: megaeth-labs/.github/.github/actions/release-candidate@main with: - stage: propose # or: cut + stage: propose # or: refresh, cut token: ${{ steps.app-token.outputs.token }} - version: ${{ inputs.version }} # propose only + version: ${{ inputs.version }} # propose and refresh version_file: Cargo.toml version_pattern: toml changelog_file: CHANGELOG.md @@ -118,6 +123,19 @@ both from `workflow-templates/release-candidate.yml`: branch and one open PR (a branch or PR the other run made first is kept). The branch is deleted on merge only by the repository's "Automatically delete head branches" setting; the step log says which applies. +- `refresh` requires `version` and an existing open, app-authored candidate + PR for it; it refuses a published `vX.Y.Z` tag before changing the tree. + It fetches that PR's head and merges its own base ref with + `git merge origin/ --no-edit`: the default branch for a trunk + candidate, or the release branch for a `base_commit` candidate. It uses + the same previous-tag-to-HEAD drafting range, keeps the entry undated, + commits the changelog only if it changed, and pushes normally to the + existing head. It never rewrites history or replaces the PR. A current + base and unchanged changelog produce no commit or push. +- A refresh that pushes a commit clears approvals under + `require_last_push_approval`; the merge must be re-approved afterwards. + The GitHub Release body is the changelog file's section verbatim, with + no date stamped by settle. - `cut` runs when that PR merges and creates `X.Y.Z` at the merge commit; it refuses a PR not opened by `pr_author`. A retry that finds the branch already at that commit exits 0 with a notice; at diff --git a/.github/actions/release-candidate/action.yml b/.github/actions/release-candidate/action.yml index 853d0e0..63803fc 100644 --- a/.github/actions/release-candidate/action.yml +++ b/.github/actions/release-candidate/action.yml @@ -1,33 +1,41 @@ name: Release Candidate description: >- 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 + default branch, drafts this release's undated changelog entry 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`, + tag, and opens a `chore/release-candidate-X.Y.Z` PR. `stage: refresh` + merges an open candidate PR's own base into its head and re-drafts the + changelog without rewriting history; any push requires renewed approval. + `stage: cut`, run on the push that merged that PR (the consumer passes the `commit` and `pr_number` outputs of `release-route`), re-reads the PR through the API, refuses anything but a merged, gutenberg-authored candidate into the default branch, and creates `release-vX.Y.Z` at the merge commit (a retry that finds it already there is a no-op; at any other commit it refuses). No tag - is created at either stage — tags come from release-publish, once, at + is created at any 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, carries the default branch's release workflows onto it, and points the candidate PR at it; that PR merges into the release branch, so `cut` never runs for it. Run - as a step in a job the consumer owns, in `environment: release`; the - consumer checks the repository out first (`fetch-depth: 0`, + as a step in a job the consumer owns; the repository-scope + `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY` provide the app + credentials. The one-reviewer `publish` environment gates only tagging, + Release creation and artifact upload. The consumer checks the repository + out first (`fetch-depth: 0`, `persist-credentials: false`). Guide: .github/actions/RELEASE.md in megaeth-labs/.github. inputs: stage: - description: "`propose` (on workflow_dispatch) or `cut` (on the candidate PR merging)." + description: "`propose` (on workflow_dispatch), `refresh` (an open candidate), or `cut` (on the candidate PR merging)." required: true token: description: >- mega-gutenberg installation token (contents, pull-requests, workflows - write) minted in an `environment: release` job; it pushes the candidate - branch, opens the PR (so CI runs) and creates the release branch. + write) from repository-scope `RELEASE_APP_CLIENT_ID` and + `RELEASE_APP_PRIVATE_KEY`; it pushes the candidate branch, opens the PR + (so CI runs) and creates the release branch. The one-reviewer `publish` + environment gates only tagging, Release creation and artifact upload. required: true dependency_token: description: >- @@ -59,7 +67,7 @@ inputs: required: false default: ".github/workflows/release-settle.yml .github/workflows/on-release.yml" version: - description: "Version to release, X.Y.Z or vX.Y.Z. Required for `propose`." + description: "Version to release, X.Y.Z or vX.Y.Z. Required for `propose` and `refresh`." required: false default: "" base_commit: @@ -113,10 +121,11 @@ inputs: default: "" pr_author: description: >- - Login the candidate PR must have been opened by for `cut` to proceed — + Login the candidate PR must have been opened by for `cut` or `refresh` to proceed — the app identity `propose` runs under. Stops a hand-made `chore/release-candidate-*` branch from driving the cut. Empty disables - the check. + the check for `cut`; `refresh` always requires a bot PR and uses + mega-gutenberg[bot] when empty. required: false default: "mega-gutenberg[bot]" git_user_name: @@ -133,8 +142,8 @@ outputs: description: Normalised X.Y.Z. value: ${{ steps.version.outputs.version }} pr_url: - description: "`propose`: URL of the candidate PR." - value: ${{ steps.pr.outputs.pr_url }} + description: "`propose` or `refresh`: URL of the candidate PR." + value: ${{ steps.pr.outputs.pr_url || steps.refresh.outputs.pr_url }} 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 }} @@ -157,7 +166,7 @@ runs: run: | set -euo pipefail case "$STAGE" in - propose) raw="$INPUT_VERSION" ;; + propose|refresh) raw="$INPUT_VERSION" ;; cut) if [[ -z "$PR_NUMBER" ]]; then echo "::error::cut needs the pr_number input (the release-route output)"; exit 1 @@ -165,7 +174,7 @@ runs: head_ref="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" | python3 -c 'import json,sys; print(json.load(sys.stdin)["head"]["ref"])')" raw="${head_ref#chore/release-candidate-}" ;; - *) echo "::error::stage must be propose or cut, got '$STAGE'"; exit 1 ;; + *) echo "::error::stage must be propose, refresh or cut, got '$STAGE'"; exit 1 ;; esac version="$(python3 "$TOOLS" normalize "$raw")" echo "version=$version" >> "$GITHUB_OUTPUT" @@ -405,8 +414,54 @@ runs: git diff --stat GITHUB_AUTH_COMMAND + # Refresh keeps the existing PR and head history, unlike a new proposal. + # Validate ownership and the unpublished version before touching the tree. + - name: Prepare candidate refresh + if: inputs.stage == 'refresh' + id: refresh + shell: bash + env: + AUTH_WRAPPER: ${{ github.action_path }}/../git-auth/with-github-auth.sh + GITHUB_AUTH_TOKEN: ${{ inputs.token }} + GH_TOKEN: ${{ inputs.token }} + VERSION: ${{ steps.version.outputs.version }} + EXPECTED_AUTHOR: ${{ inputs.pr_author }} + run: | + "$AUTH_WRAPPER" bash --noprofile --norc -e -o pipefail <<'GITHUB_AUTH_COMMAND' + set -euo pipefail + head="chore/release-candidate-$VERSION" + prs="$(gh pr list --head "$head" --state open --json number,isCrossRepository \ + | python3 -c 'import json,sys; print("\n".join(str(p["number"]) for p in json.load(sys.stdin) if not p.get("isCrossRepository")))')" + if [[ -z "$prs" || "$prs" == *$'\n'* ]]; then + echo "::error::refresh needs exactly one open candidate PR for v$VERSION"; exit 1 + fi + pr_json="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$prs")" + pr_field() { python3 -c 'import functools,json,sys; v=functools.reduce(lambda d,k: d.get(k) if isinstance(d,dict) else None, sys.argv[1].split("."), json.load(sys.stdin)); print("" if v is None else v)' "$1" <<< "$pr_json"; } + author="${EXPECTED_AUTHOR:-mega-gutenberg[bot]}" + if [[ "$(pr_field state)" != "open" || "$(pr_field head.ref)" != "$head" \ + || "$(pr_field head.repo.full_name)" != "$GITHUB_REPOSITORY" \ + || "$(pr_field user.login)" != "$author" || "$(pr_field user.type)" != "Bot" ]]; then + echo "::error::refresh requires an open, app-authored candidate PR from this repository ($author)"; exit 1 + fi + if [[ -n "$(git ls-remote --tags origin "refs/tags/v$VERSION")" ]]; then + echo "::error::tag v$VERSION already exists; refusing to refresh a published version"; exit 1 + fi + base="$(pr_field base.ref)" + git fetch --quiet --tags origin \ + "refs/heads/$head:refs/remotes/origin/$head" \ + "refs/heads/$base:refs/remotes/origin/$base" + original="$(git rev-parse "origin/$head")" + git checkout --quiet --detach "$original" + git merge "origin/$base" --no-edit + { + echo "head=$head" + echo "original=$original" + echo "pr_url=$(pr_field html_url)" + } >> "$GITHUB_OUTPUT" + GITHUB_AUTH_COMMAND + - name: Draft changelog - if: inputs.stage == 'propose' && inputs.changelog_file != '' && steps.bump.outputs.skipped != 'true' + if: inputs.changelog_file != '' && (inputs.stage == 'refresh' || (inputs.stage == 'propose' && steps.bump.outputs.skipped != 'true')) id: changelog shell: bash env: @@ -416,15 +471,14 @@ runs: run: | set -euo pipefail latest="$(git tag -l 'v*' | python3 "$TOOLS" latest-tag)" - # 1. Sync the previous release's entry from its tag. Settle stamped - # the date (and any drift) on the release branch and nothing - # back-merges it: this PR is where the default branch catches up. + # 1. Sync the previous release's entry verbatim from its tag: + # this PR is where the default branch catches up. synced="none" if [[ -n "$latest" ]] && git show "$latest:$CHANGELOG" > "$RUNNER_TEMP/changelog-$latest.md" 2>/dev/null; then synced="$(python3 "$TOOLS" changelog-copy "$RUNNER_TEMP/changelog-$latest.md" "$CHANGELOG" "$latest")" fi # 2. Draft this release's entry from the commits since that tag. The - # heading carries the version only; settle stamps the date. + # heading carries the version only and remains undated. range="HEAD" [[ -n "$latest" ]] && range="$latest..HEAD" notes="$RUNNER_TEMP/release-notes-v$VERSION.md" @@ -438,6 +492,33 @@ runs: echo "changelog: previous entry ${latest:-} $synced; v$VERSION entry $drafted" git diff --stat + - name: Push candidate refresh + if: inputs.stage == 'refresh' + shell: bash + env: + AUTH_WRAPPER: ${{ github.action_path }}/../git-auth/with-github-auth.sh + GITHUB_AUTH_TOKEN: ${{ inputs.token }} + VERSION: ${{ steps.version.outputs.version }} + CHANGELOG: ${{ inputs.changelog_file }} + HEAD_REF: ${{ steps.refresh.outputs.head }} + ORIGINAL: ${{ steps.refresh.outputs.original }} + run: | + "$AUTH_WRAPPER" bash --noprofile --norc -e -o pipefail <<'GITHUB_AUTH_COMMAND' + set -euo pipefail + if [[ -n "$CHANGELOG" ]]; then + git add -- "$CHANGELOG" + if ! git diff --cached --quiet; then + git commit --quiet -m "chore(release): refresh candidate v$VERSION" + fi + fi + if [[ "$(git rev-parse HEAD)" != "$ORIGINAL" ]]; then + git push --quiet origin "HEAD:refs/heads/$HEAD_REF" + echo "refreshed $HEAD_REF; the candidate PR must be re-approved after this push" + else + echo "candidate v$VERSION is already current; nothing pushed" + fi + GITHUB_AUTH_COMMAND + # The candidate branch is never rewritten. A re-dispatch supersedes the # open candidate instead: it closes that PR with a comment, deletes the # branch, pushes a fresh one as a new ref and opens a fresh PR, so this @@ -554,9 +635,11 @@ runs: echo echo "It creates **no tag**; the tag is cut once, at settlement, by \`release-settle\` → \`release-publish\`." echo - echo "Changelog: this release's entry drafted under \`## v$VERSION\` (settle stamps the date on the release branch);" + echo "Changelog: this release's entry is undated under \`## v$VERSION\`; the GitHub Release body is the file's section verbatim." echo "previous release's entry synced from its tag: $SYNCED." echo + echo "A refresh pushes a commit and therefore clears approvals under \`require_last_push_approval\`, so the merge must be re-approved afterwards." + echo echo "" } > "$body" if pr_url="$(gh pr create --base "$base" --head "$head" --title "$title" --body-file "$body")"; then diff --git a/workflow-templates/release-candidate.yml b/workflow-templates/release-candidate.yml index abf6061..da96ddf 100644 --- a/workflow-templates/release-candidate.yml +++ b/workflow-templates/release-candidate.yml @@ -12,12 +12,11 @@ name: Release Candidate # aims the candidate PR at it, so that PR never touches the default branch and # `route` never selects `cut` for it. # -# Every write is made by the release GitHub App (mega-gutenberg). Its token is -# minted only in jobs that run in the `release` environment: create that -# environment with a deployment policy of the default branch and `release-v*` -# branches, and no required reviewers. The decision to release is made by -# merging the candidate PR, not by a click on the run. `route` is read-only -# and uses the job token. +# Every write is made by the release GitHub App (mega-gutenberg). Store +# RELEASE_APP_CLIENT_ID as a repository variable and RELEASE_APP_PRIVATE_KEY +# as a repository secret. Branch and PR jobs need no environment; tagging and +# publishing are review-gated in release-settle. `route` is read-only and uses +# the job token. # # Guide: https://github.com/megaeth-labs/.github/blob/main/.github/actions/RELEASE.md @@ -51,7 +50,6 @@ jobs: group: ${{ github.workflow }}-propose cancel-in-progress: false runs-on: ubuntu-latest - environment: release steps: - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: release-token @@ -85,6 +83,83 @@ jobs: # dependency_token is what the bump command uses to read other # repositories, and the job token reads this one) + # Only trunk candidates follow trunk pushes; base_commit candidates belong + # to their release branch and must never pick up changes from trunk. + refresh-candidates: + if: github.event_name == 'push' && github.ref_name == github.event.repository.default_branch + runs-on: ubuntu-latest + permissions: + pull-requests: read + outputs: + prs: ${{ steps.candidates.outputs.prs }} + steps: + - id: candidates + env: + GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + prs="$(gh api --method GET --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/pulls" \ + -f state=open -f base="$DEFAULT_BRANCH" -f per_page=100 \ + --jq '[.[][] | select(.head.ref | startswith("chore/release-candidate-")) | select(.head.repo.full_name == .base.repo.full_name) | .number]')" + echo "prs=$(jq -c . <<< "$prs")" >> "$GITHUB_OUTPUT" + + refresh: + needs: refresh-candidates + if: needs.refresh-candidates.outputs.prs != '[]' + strategy: + fail-fast: false + matrix: + pr: ${{ fromJSON(needs.refresh-candidates.outputs.prs) }} + concurrency: + group: ${{ github.workflow }}-refresh-${{ matrix.pr }} + cancel-in-progress: false + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + # Recheck after queueing: a candidate may have closed or changed base + # while another refresh held its concurrency slot. + - id: candidate + env: + GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + PR_NUMBER: ${{ matrix.pr }} + run: | + version="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \ + --jq 'select(.state == "open") | select(.base.ref == env.DEFAULT_BRANCH) | select(.head.repo.full_name == .base.repo.full_name) | .head.ref | select(startswith("chore/release-candidate-")) | ltrimstr("chore/release-candidate-")')" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + if: steps.candidate.outputs.version != '' + id: release-token + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: megaeth-labs + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + permission-workflows: write + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + if: steps.candidate.outputs.version != '' + with: + fetch-depth: 0 + persist-credentials: false + + - uses: megaeth-labs/.github/.github/actions/release-candidate@main + if: steps.candidate.outputs.version != '' + with: + stage: refresh + token: ${{ steps.release-token.outputs.token }} + version: ${{ steps.candidate.outputs.version }} + pr_number: ${{ matrix.pr }} + version_file: VERSION + version_pattern: plain + changelog_file: CHANGELOG.md + # Read-only: decides from the pushed commit whether it is a merged candidate # PR that targeted the default branch. One proposed with `base_commit` merges # into the release branch propose already cut, and routes to nothing here. @@ -120,7 +195,6 @@ jobs: group: ${{ github.workflow }}-cut cancel-in-progress: false runs-on: ubuntu-latest - environment: release steps: - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: release-token From 6b69ad9695954155db55fb594b2c0f45603e01ba Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:14:23 +0800 Subject: [PATCH 06/10] feat(release): gate tagging behind the publish environment The settle publish job and on-release's binary job are the only jobs behind the review-gated publish environment; every PR-creating job reads the app credentials from repository scope and declares no environment. --- workflow-templates/on-release.yml | 15 +++++++------- workflow-templates/release-settle.yml | 30 ++++++++++++++++----------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/workflow-templates/on-release.yml b/workflow-templates/on-release.yml index 85ffaa1..f3ba731 100644 --- a/workflow-templates/on-release.yml +++ b/workflow-templates/on-release.yml @@ -19,18 +19,17 @@ name: On Release # settled commit lacks a workflow the default branch has. A rehearsal # likewise needs a tag whose tree has this file. # -# Credentials live in the `publish` environment: create it with a deployment -# policy of "selected tags: v*" and put CARGO_REGISTRY_TOKEN / GCP_AUTH_KEY -# in it as environment secrets. A job can then use them only when the run's -# ref is a v* tag — a release event or a tag-ref rehearsal — never from a -# branch build. Give it no required reviewers: the human gate is merging the -# settle PR, and a second click here only holds up the uploads. +# Put CARGO_REGISTRY_TOKEN / GCP_AUTH_KEY in the `publish` environment as +# environment secrets. Require reviewers and allow the default branch, +# `release-v*` branches (release-settle tagging) and `v*` tags (uploads). +# These jobs require a tag ref — a release event or a tag-ref rehearsal — +# before building or publishing. # # Release-pipeline jobs mint only release-app (mega-gutenberg) tokens. A build # job that reads private dependency repositories in the `publish` environment # mints one with `permission-contents: read` and `repositories:` naming only -# those repositories (never this one); it needs RELEASE_APP_CLIENT_ID and -# RELEASE_APP_PRIVATE_KEY in `publish` too. +# those repositories (never this one). Store RELEASE_APP_CLIENT_ID as a +# repository variable and RELEASE_APP_PRIVATE_KEY as a repository secret. # # Each target is its own job so a hiccup in one never blocks another. Delete # the jobs this repository does not need; keep the shared actions at @main. diff --git a/workflow-templates/release-settle.yml b/workflow-templates/release-settle.yml index 477fdf7..57438aa 100644 --- a/workflow-templates/release-settle.yml +++ b/workflow-templates/release-settle.yml @@ -9,16 +9,18 @@ name: Release Settle # publish that settle PR was just merged: create the annotated tag at the # merge commit and the GitHub Release with the changelog section # as notes. -# Merging the settle PR is the release decision, so nothing waits for a click -# on the run. To refresh every eligible release branch by hand, dispatch this -# workflow (optionally with `version` to refresh just one); it never publishes. +# Merging the settle PR is the release decision on push. Dispatch with `version` +# to publish a clean release branch through the review-gated `publish` +# environment, or refresh it when settlement is needed. A blank version +# refreshes every eligible release branch. # -# Every write is made by the release GitHub App (mega-gutenberg), whose token -# is minted only in jobs that run in the `release` environment. Create that -# environment with a deployment policy of the default branch and `release-v*` -# branches, and no required reviewers. The app is a bypass actor on the `v*` -# tag ruleset only; the `release-*` branch ruleset still requires the reviewed -# settle PR. `route` is read-only and uses the job token. +# Every write is made by the release GitHub App (mega-gutenberg). Store +# RELEASE_APP_CLIENT_ID as a repository variable and RELEASE_APP_PRIVATE_KEY +# as a repository secret. Refresh needs no environment. Only publish uses the +# `publish` environment, with required reviewers and a deployment policy for +# the default branch, `release-v*` branches and `v*` tags (artifact uploads). +# The app is a bypass actor on the `v*` tag ruleset only; the `release-*` branch +# ruleset still requires the reviewed settle PR. `route` uses the job token. # # This file must exist on the release branch (it does, when the branch is cut # from a default branch that has it); a push to the branch runs the copy of @@ -32,7 +34,7 @@ on: workflow_dispatch: inputs: version: - description: "Version to refresh (X.Y.Z); blank: every eligible release branch" + description: "Version to settle (X.Y.Z); blank: refresh every eligible release branch" required: false type: string default: "" @@ -49,6 +51,7 @@ jobs: pull-requests: read outputs: action: ${{ steps.route.outputs.action }} + settle_source: ${{ steps.route.outputs.settle_source }} targets: ${{ steps.route.outputs.targets }} release_branch: ${{ steps.route.outputs.release_branch }} commit: ${{ steps.route.outputs.commit }} @@ -80,7 +83,6 @@ jobs: group: release-settle-refresh-${{ matrix.target.release_branch }} cancel-in-progress: false runs-on: ubuntu-latest - environment: release steps: - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: release-token @@ -110,6 +112,8 @@ jobs: publish: needs: route + # Route permits a merged settle PR on push or a clean branch on dispatch; + # both publish paths wait for the publish environment's review. if: needs.route.outputs.action == 'publish' # One publish per release branch: the tag guard and the tag push are two # steps, so a re-run must queue behind a run in flight, never overlap it. @@ -117,7 +121,7 @@ jobs: group: release-settle-publish-${{ needs.route.outputs.release_branch }} cancel-in-progress: false runs-on: ubuntu-latest - environment: release + environment: publish steps: - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: release-token @@ -140,6 +144,8 @@ jobs: token: ${{ steps.release-token.outputs.token }} commit: ${{ needs.route.outputs.commit }} pr_number: ${{ needs.route.outputs.pr_number }} + settle_source: ${{ needs.route.outputs.settle_source || 'pr' }} + release_branch: ${{ needs.route.outputs.release_branch }} version_file: VERSION version_pattern: plain changelog_file: CHANGELOG.md From 236c3a5523fdb8c892539b93214753169855cfdb Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:14:23 +0800 Subject: [PATCH 07/10] feat(app-token-lint): allow the release app only in release workflows Replaces L2's environment requirement with a file allowlist for the release workflow files and aligns L5, keeping L1, L3, L4 and L6 unchanged. --- .github/actions/app-token-lint/README.md | 17 +++-- .github/actions/app-token-lint/action.yml | 13 ++-- .github/scripts/app_token_lint.py | 28 +++---- .github/scripts/test_app_token_lint.py | 90 +++++++++++++++++++---- 4 files changed, 111 insertions(+), 37 deletions(-) diff --git a/.github/actions/app-token-lint/README.md b/.github/actions/app-token-lint/README.md index 5fdd549..d0ceebe 100644 --- a/.github/actions/app-token-lint/README.md +++ b/.github/actions/app-token-lint/README.md @@ -3,7 +3,7 @@ `uses: megaeth-labs/.github/.github/actions/app-token-lint@main` -Fail a workflow change that mints GitHub App tokens the wrong way. Maxwell (the CI app) may not request a `permission-: write` other than pull-requests and issues; Gutenberg (the release app) may be minted only in a job of the `release` environment, or in a `publish` job when the token asks for exactly `permission-contents: read`; release workflows (those using the release-candidate, release-settle, release-publish or release-route actions, and on-release.yml) mint no Maxwell token, never name their own repository in a read-only dependency token, and never use `mega-maxwell[bot]` as a git identity or PR author filter; and `environment: release` appears only in release-candidate.yml, release-settle.yml and on-release.yml. Violations are reported as `::error file=::/: : `; unreadable or malformed YAML is an error too. No network. +Fail a workflow change that mints GitHub App tokens the wrong way. Maxwell (the CI app) may not request a `permission-: write` other than pull-requests and issues; Gutenberg (the release app) reads `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY` from repository scope and may be minted only in `release-candidate.yml`, `release-settle.yml` or `on-release.yml`, with or without an environment and whatever contents permission the job needs, because L6 still forces an explicit `permission-*` list either way; release workflows (those using the release-candidate, release-settle, release-publish or release-route actions, and on-release.yml) mint no Maxwell token, never name their own repository in a read-only dependency token, and never use `mega-maxwell[bot]` as a git identity or PR author filter; and the one-reviewer `publish` environment gates only tagging, Release creation and artifact upload. Violations are reported as `::error file=::/: : `; unreadable or malformed YAML is an error too. No network. Standalone action; no family guide. @@ -60,14 +60,21 @@ another directory or several, e.g. `paths: .github/workflows workflow-templates` ## Notes - Rules, in the order violations name them: **L1** Maxwell tokens write only - pull-requests and issues; **L2** Gutenberg tokens need `environment: release` - (or `publish` with exactly `permission-contents: read`); **L3** release + pull-requests and issues; **L2'** Gutenberg tokens may be minted only in + `release-candidate.yml`, `release-settle.yml` or `on-release.yml`, + with contents write allowed even without an + environment; **L3** release workflows mint no Maxwell token and no read-only dependency token naming their own repository; **L4** release workflows never use `mega-maxwell[bot]`; - **L5** `environment: release` only in `release-candidate.yml`, - `release-settle.yml` and `on-release.yml`; **L6** a Maxwell or Gutenberg + **L5** `environment: release`, and `environment: publish` in release workflows, + only in the same exact three files as L2' + (non-release workflows may still use `publish`); **L6** a Maxwell or Gutenberg token, and any app token in a release job, requests at least one `permission-` (without one it gets every permission of the installation). +- The shared L2'/L5 allowlist matches the shipped release workflow templates + exactly; a new `release-publish-` filename grants no token or gated-environment + rights. Neither `release-publish.yml` nor `release-publish-rust-crates.yml` + is a shipped workflow template. - A token step is a step using `actions/create-github-app-token`; Maxwell is recognised by `MEGA_MAXWELL_`, `CI_APP_ID` or `MEGA_CI_APP_PK` in `client-id`, `app-id` or `private-key`, Gutenberg by `RELEASE_APP_`. diff --git a/.github/actions/app-token-lint/action.yml b/.github/actions/app-token-lint/action.yml index 0c79263..827fbc3 100644 --- a/.github/actions/app-token-lint/action.yml +++ b/.github/actions/app-token-lint/action.yml @@ -2,14 +2,17 @@ name: App Token Lint description: >- Fail a workflow change that mints GitHub App tokens the wrong way. Maxwell (the CI app) may not request a `permission-: write` other than - pull-requests and issues; Gutenberg (the release app) may be minted only in a - job of the `release` environment, or in a `publish` job when the token asks - for exactly `permission-contents: read`; release workflows (those using the + pull-requests and issues; Gutenberg (the release app) reads + `RELEASE_APP_CLIENT_ID` and `RELEASE_APP_PRIVATE_KEY` from repository scope and + may be minted only in `release-candidate.yml`, `release-settle.yml` or + `on-release.yml`, with or without an environment and whatever contents + permission the job needs, because L6 still forces an explicit + `permission-*` list either way; release workflows (those using the release-candidate, release-settle, release-publish or release-route actions, and on-release.yml) mint no Maxwell token, never name their own repository in a read-only dependency token, and never use `mega-maxwell[bot]` as a git - identity or PR author filter; and `environment: release` appears only in - release-candidate.yml, release-settle.yml and on-release.yml. Violations are + identity or PR author filter; and the one-reviewer `publish` environment + gates only tagging, Release creation and artifact upload. Violations are reported as `::error file=::/: : `; unreadable or malformed YAML is an error too. No network. diff --git a/.github/scripts/app_token_lint.py b/.github/scripts/app_token_lint.py index 45a3531..d4d559b 100644 --- a/.github/scripts/app_token_lint.py +++ b/.github/scripts/app_token_lint.py @@ -7,22 +7,22 @@ names CI_APP_ID / MEGA_CI_APP_PK). Its commits and PRs are what automation shows as `mega-maxwell[bot]`; it never touches a release. Gutenberg Release app (vars.RELEASE_APP_CLIENT_ID / secrets.RELEASE_APP_PRIVATE_KEY), - only usable from a job in the `release` environment. + only usable from an allowlisted release workflow file. Rules, each reported as `::error file=::/: : `: L1 A Maxwell token may not request `permission-: write` except for pull-requests and issues (a permission that is not a literal value, such as an expression, cannot be verified and is refused too). - L2 A Gutenberg token must sit in a job whose `environment` is `release`, or in a - `publish` job when the token requests exactly `permission-contents: read` - (private-dependency reads in on-release build jobs). + L2' A Gutenberg token may be minted only in release-candidate.yml, + release-settle.yml or on-release.yml, regardless + of its job's environment or requested permissions. L3 In a release workflow, no Maxwell token at all; and a Gutenberg read-only dependency token's `repositories:` must not name the repository itself. L4 A release workflow may not use `mega-maxwell[bot]` as a git identity or a PR author filter. - L5 `environment: release` may appear only in release-candidate.yml, - release-settle.yml and on-release.yml. + L5 `environment: release`, and `publish` in a release workflow, may appear + only in release-candidate.yml, release-settle.yml and on-release.yml. L6 A Maxwell or Gutenberg token, and any app token in a release workflow or a `release`-environment job, must request at least one `permission-`: without one, create-github-app-token returns every permission the @@ -53,7 +53,7 @@ MAXWELL_WRITABLE = {"permission-pull-requests", "permission-issues"} RELEASE_USES = re.compile(r"megaeth-labs/\.github/\.github/actions/release-(candidate|settle|publish|route)") ON_RELEASE = "on-release" -RELEASE_ENVIRONMENT_FILES = {"release-candidate", "release-settle", ON_RELEASE} +RELEASE_WORKFLOW_FILES = {"release-candidate.yml", "release-settle.yml", "on-release.yml"} MAXWELL_IDENTITY = "mega-maxwell[bot]" WORKFLOW_SUFFIXES = (".yml", ".yaml") @@ -129,11 +129,11 @@ def is_release_workflow(path: str, doc: dict) -> bool: return False -def lint_job(job_id: str, job: dict, stem: str, release: bool, repo: str | None) -> list[Violation]: +def lint_job(job_id: str, job: dict, filename: str, release: bool, repo: str | None) -> list[Violation]: found: list[Violation] = [] environment = environment_name(job) - if environment == "release" and stem not in RELEASE_ENVIRONMENT_FILES: - found.append((job_id, "-", "L5", "environment `release` is only for release-candidate.yml, release-settle.yml and on-release.yml")) + if (environment == "release" or (release and environment == "publish")) and filename not in RELEASE_WORKFLOW_FILES: + found.append((job_id, "-", "L5", f"environment `{environment}` is only for {', '.join(sorted(RELEASE_WORKFLOW_FILES))}")) if release and mentions_maxwell_identity({k: v for k, v in job.items() if k != "steps"}): found.append((job_id, "-", "L4", f"{MAXWELL_IDENTITY} is not a release identity")) steps = job.get("steps") if isinstance(job.get("steps"), list) else [] @@ -161,8 +161,8 @@ def lint_job(job_id: str, job: dict, stem: str, release: bool, repo: str | None) if release: found.append((job_id, label, "L3", "no Maxwell token in a release workflow; mint a Gutenberg token instead")) if GUTENBERG_MARKER in refs: - if not (environment == "release" or (environment == "publish" and is_read_only_contents(permissions))): - found.append((job_id, label, "L2", f"Gutenberg token needs `environment: release` (or `publish` with only permission-contents: read), job has {environment or 'none'}")) + if filename not in RELEASE_WORKFLOW_FILES: + found.append((job_id, label, "L2'", f"Gutenberg token may be minted only in {', '.join(sorted(RELEASE_WORKFLOW_FILES))}; file is {filename}")) if release and repo and is_read_only_contents(permissions) and repo.lower() in repository_names(with_): found.append((job_id, label, "L3", f"read-only dependency token names the repository itself ({repo})")) return found @@ -182,13 +182,13 @@ def lint_source(path: str, text: str, repo: str | None) -> list[str]: if jobs is not None and not isinstance(jobs, dict): return [error_line(path, ("-", "-", "parse", "`jobs` is not a mapping"))] release = is_release_workflow(path, doc) - stem = Path(path).stem + filename = Path(path).name found: list[Violation] = [] if release and mentions_maxwell_identity({k: v for k, v in doc.items() if k != "jobs"}): found.append(("(workflow)", "-", "L4", f"{MAXWELL_IDENTITY} is not a release identity")) for job_id, job in (jobs or {}).items(): if isinstance(job, dict): - found.extend(lint_job(str(job_id), job, stem, release, repo)) + found.extend(lint_job(str(job_id), job, filename, release, repo)) else: found.append((str(job_id), "-", "parse", "job is not a mapping")) return [error_line(path, v) for v in found] diff --git a/.github/scripts/test_app_token_lint.py b/.github/scripts/test_app_token_lint.py index d28c181..ec44b09 100644 --- a/.github/scripts/test_app_token_lint.py +++ b/.github/scripts/test_app_token_lint.py @@ -20,7 +20,7 @@ "gutenberg": ("${{ vars.RELEASE_APP_CLIENT_ID }}", "${{ secrets.RELEASE_APP_PRIVATE_KEY }}"), "other": ("${{ vars.SOMETHING_ELSE_ID }}", "${{ secrets.SOMETHING_ELSE_KEY }}"), } -LINE = re.compile(r"^::error file=(?P[^:]*)::(?P[^/]+)/(?P.+?): (?P\w+): ") +LINE = re.compile(r"^::error file=(?P[^:]*)::(?P[^/]+)/(?P.+?): (?PL\d+'?|parse|input): ") def job(name="build", *, environment=None, app="maxwell", permissions=None, repositories=None, @@ -95,6 +95,10 @@ def test_gutenberg_token_without_permissions_fails_even_in_release(self): text = workflow(job(app="gutenberg", environment="release", release_step=True)) self.assertEqual(rules(lint("release-settle.yml", text)), [("L6", "build")]) + def test_gutenberg_token_without_permissions_fails_without_environment(self): + text = workflow(job(app="gutenberg")) + self.assertEqual(rules(lint("release-candidate.yml", text)), [("L6", "build")]) + def test_any_app_token_in_a_release_job_needs_permissions(self): self.assertEqual(rules(lint("ci.yml", workflow(job(app="other", release_step=True)))), [("L6", "build")]) text = workflow(job(app="other", environment="release")) @@ -107,7 +111,7 @@ def test_a_single_read_permission_is_enough(self): self.assertEqual(lint("ci.yml", workflow(job(permissions={"contents": "read"}))), []) -class L2GutenbergEnvironment(unittest.TestCase): +class L2GutenbergFileAllowlist(unittest.TestCase): WRITE = {"contents": "write", "pull-requests": "write"} def test_release_environment_as_string_passes(self): @@ -121,20 +125,46 @@ def test_publish_environment_with_only_contents_read_passes(self): text = workflow(job(app="gutenberg", environment="publish", permissions={"contents": "read"}, repositories=["rust-rocksdb"])) self.assertEqual(lint("on-release.yml", text), []) - def test_no_environment_fails(self): - self.assertEqual(rules(lint("ci.yml", workflow(job(app="gutenberg", permissions=self.WRITE)))), [("L2", "build")]) + def test_contents_write_without_environment_in_allowed_files_passes(self): + text = workflow(job(app="gutenberg", permissions={"contents": "write"})) + for name in ("release-candidate.yml", "release-settle.yml", ".github/workflows/on-release.yml"): + with self.subTest(name=name): + self.assertEqual(lint(name, text), []) + + def test_contents_write_in_non_release_file_fails_naming_file(self): + text = workflow(job(app="gutenberg", permissions={"contents": "write"})) + lines = lint("daily-audit.yml", text) + self.assertEqual(rules(lines), [("L2'", "build")]) + self.assertTrue(lines[0].startswith("::error file=daily-audit.yml::build/token: L2': ")) + + def test_release_action_does_not_bypass_file_allowlist(self): + text = workflow(job(app="gutenberg", permissions=self.WRITE, release_step=True)) + self.assertEqual(rules(lint("daily-audit.yml", text)), [("L2'", "build")]) + + def test_other_environment_in_allowed_file_passes(self): + text = workflow(job(app="gutenberg", environment="staging", permissions=self.WRITE)) + self.assertEqual(lint("release-candidate.yml", text), []) + + def test_similar_filenames_are_not_allowlisted(self): + text = workflow(job(app="gutenberg", permissions=self.WRITE)) + for name in ("release-candidate.yaml", "release-publish.yml", "release-publish-x.yaml", + "release-publish-rust-crates.yml", "release-publish-anything.yml", + "release-publish-binaries.yml", "daily-release-publish-x.yml"): + with self.subTest(name=name): + self.assertEqual(rules(lint(name, text)), [("L2'", "build")]) - def test_other_environment_fails(self): - text = workflow(job(app="gutenberg", environment="staging", permissions={"contents": "read"})) - self.assertEqual(rules(lint("ci.yml", text)), [("L2", "build")]) + def test_dispatch_trigger_does_not_admit_arbitrary_publish_file(self): + text = workflow(job(app="gutenberg", permissions={"contents": "write"})).replace( + "on: push", "on: workflow_dispatch") + self.assertEqual(rules(lint("release-publish-anything.yml", text)), [("L2'", "build")]) - def test_publish_environment_with_write_fails(self): + def test_publish_environment_with_write_passes(self): text = workflow(job(app="gutenberg", environment="publish", permissions={"contents": "write"})) - self.assertEqual(rules(lint("on-release.yml", text)), [("L2", "build")]) + self.assertEqual(lint("on-release.yml", text), []) - def test_publish_environment_with_more_than_contents_read_fails(self): + def test_publish_environment_with_more_than_contents_read_passes(self): text = workflow(job(app="gutenberg", environment="publish", permissions={"contents": "read", "pull-requests": "read"})) - self.assertEqual(rules(lint("on-release.yml", text)), [("L2", "build")]) + self.assertEqual(lint("on-release.yml", text), []) class L3ReleaseWorkflows(unittest.TestCase): @@ -204,14 +234,47 @@ def test_release_environment_as_mapping_in_another_file_fails(self): text = workflow(job(app=None, environment="{ name: release }")) self.assertEqual(rules(lint("release-publish.yml", text)), [("L5", "build")]) + def test_gated_environments_and_gutenberg_share_exact_file_allowlist(self): + for environment in ("release", "{ name: release }", "publish", "{ name: publish }"): + text = workflow(job(app="gutenberg", environment=environment, + permissions={"contents": "write"}, release_step=True)).replace( + "/release-settle@main", "/release-publish-rust-crates@main") + for name in ("release-candidate.yml", "release-settle.yml", "on-release.yml"): + with self.subTest(environment=environment, name=name): + self.assertEqual(lint(name, text), []) + for name in ("release-publish.yml", "release-publish-rust-crates.yml", + "release-publish-anything.yml", "release-candidate.yaml"): + with self.subTest(environment=environment, name=name): + lines = lint(name, text) + self.assertEqual(rules(lines), [("L5", "build"), ("L2'", "build")]) + self.assertIn(f"environment `{ 'publish' if 'publish' in environment else 'release' }`", + lines[0]) + def test_release_environment_in_the_three_allowed_files_passes(self): text = workflow(job(app=None, environment="release")) for name in ("release-candidate.yml", "release-settle.yml", "on-release.yml"): with self.subTest(name): self.assertEqual(lint(name, text), []) + def test_publish_environment_in_the_three_allowed_files_passes(self): + for environment in ("publish", "{ name: publish }"): + text = workflow(job(app=None, environment=environment, release_step=True)) + for name in ("release-candidate.yml", "release-settle.yml", "on-release.yml"): + with self.subTest(environment=environment, name=name): + self.assertEqual(lint(name, text), []) + + def test_publish_environment_in_other_release_workflows_fails(self): + for environment in ("publish", "{ name: publish }"): + text = workflow(job(app=None, environment=environment, release_step=True)) + for name in ("ci.yml", "release-publish-binaries.yml"): + with self.subTest(environment=environment, name=name): + self.assertEqual(rules(lint(name, text)), [("L5", "build")]) + def test_other_environments_are_free(self): - self.assertEqual(lint("ci.yml", workflow(job(app=None, environment="publish"))), []) + self.assertEqual(lint("ci.yml", workflow(job(app=None, environment="staging"))), []) + + def test_publish_environment_outside_release_workflows_is_free(self): + self.assertEqual(lint("snapshot-publish.yml", workflow(job(app=None, environment="publish"))), []) class MalformedInput(unittest.TestCase): @@ -251,7 +314,8 @@ def run_cli(self, *args, repository=None): env = {k: v for k, v in os.environ.items() if k != "GITHUB_REPOSITORY"} if repository: env["GITHUB_REPOSITORY"] = repository - return subprocess.run([sys.executable, "-I", str(SCRIPT), *args], capture_output=True, text=True, env=env) + # Use the same dependency environment as the suite, including user-installed PyYAML. + return subprocess.run([sys.executable, str(SCRIPT), *args], capture_output=True, text=True, env=env) def directory(self, files): tmp = tempfile.TemporaryDirectory() From 49dc131bc08d880919806cd06cf93e5a7f692969 Mon Sep 17 00:00:00 2001 From: Troublor <14846504+Troublor@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:14:23 +0800 Subject: [PATCH 08/10] docs(release): describe the state-derived settlement Rewrites the releasing guide and the shared action guide for the undated entry, the dispatch settlement, the publish environment and the candidate refresh. --- .github/actions/RELEASE.md | 562 ++++++++++++++++++++----------------- docs/RELEASING.md | 140 ++++++--- 2 files changed, 403 insertions(+), 299 deletions(-) diff --git a/.github/actions/RELEASE.md b/.github/actions/RELEASE.md index ab5318e..3a719cc 100644 --- a/.github/actions/RELEASE.md +++ b/.github/actions/RELEASE.md @@ -4,14 +4,16 @@ How MegaETH repositories cut, settle and publish releases with the shared actions in this directory, how to install the pipeline in a repository, which options exist, and what to do when something goes wrong. -The code is next to this file: `release-candidate/`, `release-settle/`, -`release-publish/` (the core), `release-verify-version/`, -`release-publish-rust-crates/`, `release-upload-artifact/`, `release-assets/`, -`release-snapshot/` (the snapshot bookends) -(the publish targets), and `release-tools/` (the text helpers they share, unit -tested by `actions-test.yml`). The reference callers a repository stamps from -are in `workflow-templates/` at the repository root. `README.md` beside this -file documents the Claude CI actions and is not needed for releases. +The code is next to this file: `release-candidate/`, `release-route/`, +`release-settle/`, `release-publish/` (the core), `release-verify-version/`, +`release-publish-rust-crates/`, `release-upload-artifact/`, `release-assets/` +(the publish targets), `release-snapshot/` (the snapshot bookends), +`release-tools/` (the text helpers they share, unit tested by +`actions-test.yml`), and `app-token-lint/` (the lint that keeps app tokens +scoped). `git-auth/` is the credential wrapper the actions run their git and +gh commands under. The reference callers a repository stamps from are in +`workflow-templates/` at the repository root. `README.md` beside this file +documents the Claude CI actions and is not needed for releases. Contents: @@ -20,63 +22,93 @@ Contents: 3. [Installing it in a repository](#installing-it-in-a-repository) 4. [Options](#options) 5. [Publish targets (`on-release.yml`)](#publish-targets-on-releaseyml) -6. [Operations and recovery](#operations-and-recovery) -7. [Reference](#reference) +6. [Snapshots (`snapshot-publish.yml`)](#snapshots-snapshot-publishyml) +7. [Operations and recovery](#operations-and-recovery) +8. [Reference](#reference) ## What it does, and the choices behind it -A release is three phases, each a workflow in the consuming repository -calling one composite action here at `@main`: +A release is four phases, each a workflow in the consuming repository calling +one composite action here at `@main`. The workflows carry no decision logic: +the read-only `release-route` action classifies every push and dispatch and +answers `cut`, `refresh`, `publish` or `skip`, with a one-line reason in the +job log. | Phase | Trigger | Result | |---|---|---| | **Candidate** | a maintainer dispatches `release-candidate.yml` with a version | a PR on the default branch that bumps the version and drafts the changelog entry; merging it creates `release-vX.Y.Z` at the merge commit | -| **Settle** | a maintainer dispatches `release-settle.yml` with the version and the release branch's tip | the dated changelog entry is committed to the release branch, the annotated tag `vX.Y.Z` is created there, and the GitHub Release is published with the entry as notes | -| **Publish targets** | the GitHub Release being published | whatever the repository ships: crates to crates.io, binaries to the artifact registry, downloads on the Release page — composed in `on-release.yml` from the publish-target actions | +| **Settle** | a push to `release-vX.Y.Z`: the branch being cut, and every later push to it | `route` answers `refresh`, and `release-settle` opens or updates one reviewed `chore/release-settle-vX.Y.Z` PR onto the release branch, carrying the changelog entry for the commits since the previous tag | +| **Publish** | the settle PR merging, or a dispatch of `release-settle.yml` with a version whose branch needs no settle PR | `release-publish` creates the annotated tag `vX.Y.Z` at the settled commit and publishes the GitHub Release with the changelog section as notes | +| **Publish targets** | the GitHub Release being published | whatever the repository ships: crates to crates.io, binaries to the artifact registry, downloads on the Release page, composed in `on-release.yml` from the publish-target actions | The choices that shape it: - **Trunk first.** The version bump and the changelog draft land on the 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 + should also land on the default branch, because the next candidate is cut 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). + (`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 - create them. -- **One human gate.** The settle dispatch is the release decision. A - `release` GitHub environment on the settle job makes the run wait for its - required reviewers; that click is the approval. An optional `settlers` - list can additionally restrict who may start a settle. Everything after - the click is mechanical. -- **The changelog has one owner per phase.** The candidate PR drafts the - entry (`## vX.Y.Z`, no date) from merged PR titles and syncs the previous - release's final entry from its tag; settlement finalises it (date, any - commits added on the release branch) on the release branch; the tag and the - Release carry the final text. Nothing is back-merged: the default branch's - copy of the latest entry has no date until the next candidate PR syncs it. + publication, on the commit that ships, and `release-publish` is the only + place in the flow that creates one. Tags are immutable: the `v*` tag + ruleset forbids updating or deleting them, and its only bypass actors are + the repository admin role and the app, so nothing else can create one. +- **The settle PR is the release decision.** A push to the release branch + never publishes directly; it opens or refreshes the settle PR, and merging + that PR publishes. When the branch is exactly the candidate merge, with no + fixes on top and no settle PR open, a maintainer can instead dispatch + `release-settle.yml` with the version: the clean state routes straight to + `publish`, and the run waits for a required reviewer of the `publish` + environment. That approval stands in for the PR. +- **Settlement is a state, not a mode.** For a versioned dispatch, + `release-route` reads the repository (the candidate PR's merge commit, + whether that commit is an ancestor of the branch tip, whether a settle PR + is open, whether the tag and the GitHub Release exist) and computes one of + five states: `clean`, `needs-settle-pr`, `resume`, `published`, `blocked`. + Clean and resume route to `publish`, needs-settle-pr routes to `refresh`, + published and blocked skip with the reason. A tag that exists at the + candidate merge without its Release (a run that died between the two + steps) is `resume`: publishing runs again and creates the missing Release. +- **The changelog has one owner per phase, and no dates.** The candidate PR + drafts the entry under an undated `## vX.Y.Z` heading from the merged PR + titles and syncs the previous release's final entry from its tag. The + settle PR re-drafts the same undated entry over the commits that actually + ship. The Release body is that section read from the tagged tree, + verbatim; nothing stamps a date anywhere. The default branch catches up in + the next candidate PR: nothing is back-merged. - **The app is the identity.** Every push, PR, tag and Release is made by - the Maxwell GitHub App (`mega-maxwell[bot]`), from a token minted in the - job. Its PRs trigger CI (a `GITHUB_TOKEN` push would not), and the - rulesets make it the only actor that can create a tag or push to a release - branch without a PR. The `cut` and publish stages refuse PRs the app did not - open. -- **Credentials are scoped to release tags.** Publish credentials live in a - `publish` environment whose deployment policy allows only `v*` tag refs, so - a branch build can never read them. The environment has no reviewers: the - human gate is settlement. + the Gutenberg GitHub App (`mega-gutenberg[bot]`), from a token minted in + the job. Its PRs trigger CI (a `GITHUB_TOKEN` push would not), the tag + ruleset makes it the only tag creator, and publish refuses PRs the app did + not open. +- **Credentials are repository-scope; one environment gates the writes.** + The app credentials are a repository variable (`RELEASE_APP_CLIENT_ID`) + and a repository secret (`RELEASE_APP_PRIVATE_KEY`), so the branch and PR + jobs mint tokens with no environment at all. Only the jobs that tag, + create the Release or upload artifacts run in the `publish` environment, + which has required reviewers and a deployment policy of the default + branch, `release-v*` branches and `v*` tags. The publish-target + credentials (`CARGO_REGISTRY_TOKEN`, `GCP_AUTH_KEY`, whatever the targets + need) live in it as environment secrets, so a branch build can never read + them. +- **A lint holds those rules.** `app-token-lint` runs on every workflow + change (the `app-token-lint.yml` template). Rule L2' allows a Gutenberg + mint only in `release-candidate.yml`, `release-settle.yml`, + `on-release.yml` and `release-publish-*.yml`; rule L6 requires every app + token in a release workflow to list its `permission-*` inputs explicitly, + because a token minted without them gets every permission of the + installation, contents write included. - **Nothing force-pushes any branch, or a tag.** Re-dispatching a candidate supersedes the open one: it closes that PR with a comment, deletes the - branch, pushes a fresh one as a new ref and opens a fresh PR. Re-running - settle fast-forwards its own PR's branch, or closes a PR it does not - maintain and starts fresh. So the pipeline works in repositories whose rulesets - ban force pushes on every branch, with no exemption. A test - (`.github/scripts/test_no_force_push.py`) fails if any shipped action or - template gains a forced push. + branch, pushes a fresh one as a new ref and opens a fresh PR. Re-running a + settle refresh updates its own PR's branch with ordinary pushes, and a PR + it does not maintain is closed and started fresh. So the pipeline works in + repositories whose rulesets ban force pushes on every branch, with no + exemption. A test (`.github/scripts/test_no_force_push.py`) fails if any + shipped action or template gains a forced push. - **Generic.** The actions know nothing about MegaETH: the version file and its format, the changelog path, the branch prefix, the app identity and every publish destination are inputs. The templates carry this @@ -84,8 +116,8 @@ The choices that shape it: ## One release, step by step -The direct-settle flow, which every migrated repository uses. Commands assume -`gh` authenticated as a maintainer and the default branch `main`. +Commands assume `gh` authenticated as a maintainer and the default branch +`main`. **1. Candidate.** @@ -93,53 +125,75 @@ The direct-settle flow, which every migrated repository uses. Commands assume gh workflow run release-candidate.yml --ref main -f version=1.2.3 ``` -Within a minute the app opens `chore(release): candidate v1.2.3` from the -branch `chore/release-candidate-1.2.3`: the version file bumped, whatever +Within a minute the app opens a candidate PR from the branch +`chore/release-candidate-1.2.3`: the version file bumped, whatever `bump_command` maintains (a lockfile, path-dependency versions), the changelog entry `## v1.2.3` drafted from the merged PR titles since the last `v*` tag (grouped by Conventional Commit type, with PR links), and the previous release's entry synced from its tag. Review it like any PR; fix -wording in it if the generated entry needs work. Re-dispatching the -candidate supersedes this PR (closed with a comment, a fresh branch and PR -opened, nothing force-pushed), so edits made on it are not carried over. - -**2. Cut.** Merging the candidate PR runs the `cut` job, which creates -`release-v1.2.3` at the merge commit. No tag yet. From here, fixes for the -release go to `release-v1.2.3` by PR, and each one must also reach `main`. - -**3. Settle.** - -```sh -gh workflow run release-settle.yml --ref main -f version=1.2.3 \ - -f commit="$(gh api repos/OWNER/REPO/git/ref/heads/release-v1.2.3 -q .object.sha)" -``` - -The run pauses at the `release` environment; a required reviewer approves it -under "Review deployments" on the run page. The dispatcher may be the -reviewer (the environment is created with self-review allowed). After the -click, the action verifies the commit is a full SHA, the release branch -exists, `v1.2.3` does not, the commit is the branch tip, the version file at -that commit says `1.2.3`, and `1.2.3` is newer than the latest tag. It warns -if the tip lacks a workflow the default branch has (see -[Operations](#operations-and-recovery)). If `settlers` is set, it checks -the dispatcher against it. Then it regenerates the notes up to the tip, writes -`## v1.2.3 (YYYY-MM-DD)` into the changelog, commits that straight onto -`release-v1.2.3` (the app bypasses the branch ruleset), creates the annotated -tag `v1.2.3` at that commit, and publishes the GitHub Release, marked latest, -with the entry as notes. About a minute after approval. - -**4. Publish targets.** The Release's `published` event starts +wording in it if the generated entry needs work. + +While the PR is open, every push to the default branch starts the workflow's +`refresh` job for it: the refresh merges the default branch into the PR's +head (a merge commit, never a rebase, so the branch history is not +rewritten) and re-drafts the entry. That push clears approvals under +`require_last_push_approval`, so the merge must be re-approved afterwards. +Re-dispatching the candidate supersedes the PR instead: it is closed with a +comment, its branch is deleted, and a fresh branch and PR are opened, so +edits made on it are not carried over. + +**2. Cut.** Merging the candidate PR is a push to the default branch; +`route` recognises the merged, app-authored candidate PR and answers `cut`, +and the cut job creates `release-v1.2.3` at the merge commit. No tag yet. +From here, fixes for the release go to `release-v1.2.3` by PR, and each one +should also reach `main`. + +**3. Settle.** The push that created the branch, and every later push to it, +runs `release-settle.yml`. Once the candidate has landed on the branch, +`route` answers `refresh` and the settle job opens (or refreshes) the PR +from `chore/release-settle-v1.2.3` onto the release branch, with the entry +for the commits since the previous tag under the undated `## v1.2.3` +heading. Review it like any PR. Nothing is published yet, and every new push +to the branch refreshes the same PR. + +**4. Publish**, one of two ways: + +- *Merge the settle PR.* The merge is a push to the release branch; `route` + re-reads the PR through the API (merged, merge commit equals the pushed + commit, head and base names, opened by the app) and checks the settled-SHA + marker in its body against the merge commit's parent, then answers + `publish`. A PR that went stale before it merged (the branch moved after + it was written) publishes nothing: `route` answers `refresh` and a fresh + PR follows. +- *Dispatch a clean branch.* + + ```sh + gh workflow run release-settle.yml --ref main -f version=1.2.3 + ``` + + When the branch tip is exactly the candidate merge, with no open settle PR + and no tag, the settlement state is `clean` and routes straight to + `publish`. A branch with commits beyond the candidate merge, or an open + settle PR, routes to `refresh` instead; a version already published, or + one whose candidate is not an ancestor of the tip, skips with the reason. + A blank `version` refreshes every eligible release branch and never + publishes. + +Both ways run the same publish job in the `publish` environment, so both +wait for a required reviewer under "Review deployments" on the run page. +After the approval, the job re-verifies the PR (for a dispatch, the merged +candidate PR, and that the release branch still points at the commit, +checked again right before the tag push), creates the annotated tag `v1.2.3` +at that commit once, and publishes the GitHub Release with the entry as +notes, marked latest only when it is newer than the current latest Release. +A version that is not newer than every other `vX.Y.Z` tag is refused before +the tag, so an older line merged late is never tagged or made latest. + +**5. Publish targets.** The Release's `published` event starts `on-release.yml`, which builds and publishes whatever the repository ships. Each target is a job; `dry_run` is off on a real release. See [Publish targets](#publish-targets-on-releaseyml). -Settling by PR instead (`settle_mode: pr`, the action's default; no -migrated repository uses it): step 3 opens `chore(release): settle v1.2.3` -onto the release branch with the dated entry, and merging that PR is the -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, @@ -150,7 +204,7 @@ 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 +The commit may be given as anything the checkout resolves: a full SHA, a 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 @@ -162,35 +216,31 @@ 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: +Step 1 then works differently, and steps 3 to 5 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. + commits this release is deliberately leaving out. `propose` also carries + the default branch's release workflows onto the old tree (the + `release_workflows` input, by default `release-settle.yml` and + `on-release.yml`), because a branch cut at an older commit would otherwise + run pre-migration workflows; a stale `release-publish.yml` found there is + deleted. - 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. + `route` only selects `cut` for a candidate merged into the default branch. + The release-branch ruleset governs this PR, so it needs its approving + review, 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 `vX.Y.Z` tag in the repository, as -usual (tags such as `v1.2.0-rc1` are not releases and are ignored). Publishing -re-checks this when the settle PR merges, so a line that a newer release -overtook while its settle PR was open is refused, never tagged. 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. If +If an attempt fails after the branch was created (a broken `bump_command`, +say), re-dispatching the same version and commit reuses the branch. If hotfix PRs have already landed on it, propose sits on the tip (it does not reset to `base_commit`) and skips the version bump when the version file already reads the requested version. A branch that does not contain @@ -198,33 +248,34 @@ already reads the requested version. A branch that does not contain ## Installing it in a repository -Everything below was done for mega-agents, mega-evm, stateless-validator and -salt; copy from one of them when in doubt. - -**1. The app must cover the repository.** The Maxwell GitHub App has to be -installed on it (organisation settings → GitHub Apps → mega-maxwell → +**1. The app must cover the repository.** The Gutenberg GitHub App has to be +installed on it (organisation settings, GitHub Apps, mega-gutenberg, repository access). Nothing here can check that for you; a missing installation shows up as the token step failing in the first run. -**2. Secret and variable access.** The workflows mint the app token from the -organisation secret `MEGA_MAXWELL_PK` and the organisation variable -`MEGA_MAXWELL_CLIENT_ID`. Both must be readable by the repository: for a -public repository the organisation's access policy must include public -repositories explicitly. +**2. Repository variable and secret.** The workflows mint the app token from +the repository variable `RELEASE_APP_CLIENT_ID` and the repository secret +`RELEASE_APP_PRIVATE_KEY`. Repository scope, not an environment: the +candidate and settle branch jobs mint tokens with no environment, so the +credentials must resolve outside one. -**3. Rulesets.** Two, both with the app and the repository admins as bypass -actors (`bypass_mode: always`): +**3. Rulesets.** Two: - `release branch`, target `branch`, on `refs/heads/release-*`: `deletion`, - `non_fast_forward`, `pull_request` (one approving review, code-owner review, - squash merges), and a `branch_name_pattern` requiring `release-vX.Y.Z`. - This is what makes every change on a release branch a reviewed PR, except - the app's settle commit. + `non_fast_forward`, `pull_request` (one approving review, squash or merge + merges), and a `branch_name_pattern` requiring `release-vX.Y.Z`. No app + bypass on this one: the settle PR is genuinely reviewed. Required checks + use `do_not_enforce_on_create: true` so the branch can be cut, and "require + branches to be up to date" is on. - `release tag`, target `tag`, on `refs/tags/v*`: `creation`, `update`, `deletion`, `non_fast_forward`, and a `tag_name_pattern` requiring - semantic-version tags. This is what makes the app the only tag creator. + semantic-version tags. Bypass actors: the repository admin role and the + app. This is what makes the app the only tag creator. -Copy them from a repository that has them rather than typing them: +A ruleset that restricts branch deletion must not cover +`refs/heads/chore/release-*`: the pipeline deletes those branches when it +supersedes or replaces a PR. Copy the rulesets from a repository that has +them rather than typing them: ```sh src=megaeth-labs/mega-agents; dst=OWNER/REPO @@ -238,111 +289,95 @@ done `RepositoryRole` actor `5` is the admin role; the `Integration` actor is the app's id. -**4. The `release` environment.** Required reviewers: the maintainers who -may approve a release. Self-review allowed, so the dispatcher can approve -their own dispatch (`prevent_self_review: false`). Deployment branch policy: -the default branch only, because that is the ref the settle workflow is -dispatched on. No secrets. +**4. The `publish` environment.** Required reviewers: the maintainers who +may approve a release. A versioned dispatch publishes without a PR, so this +review is its approval. Deployment policy: the default branch, `release-v*` +branches and `v*` tags. The settle publish job runs on the first two, the +upload jobs on the last; a ref outside the policy can never enter the +environment. The publish-target credentials go in as environment secrets +(`CARGO_REGISTRY_TOKEN`, `GCP_AUTH_KEY`, whatever the targets need); an +environment secret wins over a repository secret of the same name, and a +repository-level copy of a publish credential should not exist at all, since +a branch build could read it. ```sh uid=$(gh api users/LOGIN -q .id) -gh api -X PUT repos/OWNER/REPO/environments/release --input - < ` (`expected` overrides, with `{name}` and `{version}`) | n/a | a mismatch warns; a probe that cannot run fails even here | - -The three migrated repositories with targets: mega-evm publishes four crates -and uploads `mega-evme` to the registry and the Release page; -stateless-validator uploads both of its binaries to the registry and the -Release page; mega-agents and salt ship nothing beyond the Release. +| `release-verify-version` | nothing: runs a version probe (`my-binary --version`) and requires ` ` (`expected` overrides, with `{name}` and `{version}`) | n/a | a mismatch warns; a probe that cannot run fails even here | ## Snapshots (`snapshot-publish.yml`) -Not every component is versioned. Deployments pin some binaries by commit — -`version: latest`, `profile: release`, and the commit — and want "build this +Not every component is versioned. Deployments pin some binaries by commit +(`version: latest`, `profile: release`, and the commit) and want "build this commit and push it", with no tag, no GitHub Release and no changelog. That is the snapshot flow: one dispatch-only workflow, `snapshot-publish.yml`, bookended by `release-snapshot`: @@ -392,40 +431,40 @@ bookended by `release-snapshot`: 1. `release-snapshot` `stage: resolve` turns the dispatch input `ref` (or the run's own commit) into the full commit, refuses it unless it is reachable from `allowed_branches`, and hands out the registry - coordinates: `version` = the commit, `path` = `/