From 72523fc2adfa12551c054c061a532edc57abee07 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 23 Oct 2026 11:32:39 +0300 Subject: [PATCH] Release on merging a release PR from staging into master A staging to master PR labelled release creates the GitHub release of every version without a tag, on the PR's head commit with its changelog section as notes, then starts the Release workflow on each tag. While the label is on an open PR, the same plan runs as a check. Releasing on merge stays off until the RELEASE_ON_MERGE repository variable is true. - release.py pending plans the releases of a merge, after checking that the merge commit holds exactly the PR's head. - The announcement reads the GitHub release, so a dispatched run announces too, naming who merged the release PR. - Changelog headings are read in one place, which skips '## ' lines in code blocks; a missing changelog is reported instead of crashing. --- .github/release/checks.py | 8 ++- .github/release/pending.py | 55 +++++++++++++++ .github/release/release.py | 31 ++++++++ .github/release/sources.py | 58 +++++++++++++-- .github/release/tests/support.py | 26 ++++++- .github/release/tests/test_pending.py | 86 +++++++++++++++++++++++ .github/release/tests/test_release_cli.py | 33 ++++++++- .github/release/tests/test_sources.py | 30 ++++---- .github/workflows/release-on-merge.yml | 84 ++++++++++++++++++++++ .github/workflows/release-tools.yml | 4 +- .github/workflows/release.yml | 41 ++++++++--- 11 files changed, 422 insertions(+), 34 deletions(-) create mode 100644 .github/release/pending.py create mode 100644 .github/release/tests/test_pending.py create mode 100644 .github/workflows/release-on-merge.yml diff --git a/.github/release/checks.py b/.github/release/checks.py index 5a42c69b..3de8799f 100644 --- a/.github/release/checks.py +++ b/.github/release/checks.py @@ -3,10 +3,14 @@ import subprocess +def is_ancestor(commit, ref, repo_root, runner=subprocess.run): + """Whether the commit is the ref or one of its ancestors.""" + return runner(["git", "-C", str(repo_root), "merge-base", "--is-ancestor", commit, ref], capture_output=True).returncode == 0 + + def branch_problems(commit, branch, repo_root, runner=subprocess.run): """Problems unless the commit is an ancestor of origin/.""" - result = runner(["git", "-C", str(repo_root), "merge-base", "--is-ancestor", commit, f"origin/{branch}"], capture_output=True) - return [] if result.returncode == 0 else [f"{commit} is not on origin/{branch}; the tag must point to that branch"] + return [] if is_ancestor(commit, f"origin/{branch}", repo_root, runner) else [f"{commit} is not on origin/{branch}; the tag must point to that branch"] def missing_checks(check_runs, required): diff --git a/.github/release/pending.py b/.github/release/pending.py new file mode 100644 index 00000000..883ce73a --- /dev/null +++ b/.github/release/pending.py @@ -0,0 +1,55 @@ +"""What a release PR from staging into master releases: one GitHub release per tag rule of staging whose version has +no tag yet, on the PR's head commit, once the merge is checked to hold exactly that commit.""" + +import subprocess +from dataclasses import dataclass + +import checks +import sources +from plan import checkout_tags, plan_for_tag + + +@dataclass(frozen=True) +class PendingRelease: + """One GitHub release the merge creates.""" + + tag: str + prerelease: bool + latest: bool + notes: str + + def to_json(self): + """Serializable form, written to pending.json for the workflow; the notes go to a file of their own.""" + return {"tag": self.tag, "prerelease": self.prerelease, "latest": self.latest} + + +def merge_problems(head, merge, repo_root, runner=subprocess.run): + """Problems unless the merge commit contains the PR's head commit and holds exactly its tree, so the commit the + releases are tagged on is what master holds.""" + if not checks.is_ancestor(head, merge, repo_root, runner): + return [f"{merge} does not contain {head}; merge the release PR with a merge commit, not a squash or a rebase"] + trees = runner(["git", "-C", str(repo_root), "rev-parse", f"{head}^{{tree}}", f"{merge}^{{tree}}"], capture_output=True, text=True, check=True).stdout.split() + if trees[0] != trees[1]: + return ["master holds changes staging lacks; merge master into staging, then update the release PR"] + return [] + + +def existing_tags(repo_root, runner=subprocess.run): + """The tags of the checkout.""" + return set(runner(["git", "-C", str(repo_root), "tag", "--list"], capture_output=True, text=True, check=True).stdout.split()) + + +def pending_releases(config, repo_root, branch, tags): + """The releases the checkout makes from the branch, in tag rule order, and the problems that must stop all of them. + A tag that exists is skipped; every other one needs its version sources and changelog ready. Only a final release of + the plain version tag is marked latest. Raises PlanError when a version is unreadable.""" + releases = [] + problems = [] + for tag in checkout_tags(config, repo_root, branch): + if tag in tags: + continue + plan = plan_for_tag(config, tag) + notes, notes_problems = sources.release_notes(repo_root, plan, config) + problems += sources.check_version_sources(repo_root, plan, config) + notes_problems + releases.append(PendingRelease(tag, plan.prerelease, tag == plan.version and not plan.prerelease, notes)) + return releases, list(dict.fromkeys(problems)) diff --git a/.github/release/release.py b/.github/release/release.py index 5f8ccd27..57246b50 100644 --- a/.github/release/release.py +++ b/.github/release/release.py @@ -16,6 +16,7 @@ import index import layout import manifest as manifest_module +import pending import sbom import sign import sources @@ -116,6 +117,28 @@ def cmd_checkout_tags(args): summary([f"### Tags {args.branch} would release"] + [f"- {tag}" for tag in tags or ["none"]]) +def cmd_pending(args): + """Lists the GitHub releases a release PR from staging into master creates and writes their notes, after checking + the merge; fails on anything that would stop one of them, so nothing is created.""" + config = load_config() + problems = pending.merge_problems(args.head, args.merge, REPO_ROOT) + releases = [] + if not problems: + try: + releases, problems = pending.pending_releases(config, REPO_ROOT, args.branch, pending.existing_tags(REPO_ROOT)) + except (PlanError, OSError) as error: + problems = [str(error)] + if not problems: + notes = Path(args.notes) + notes.mkdir(parents=True, exist_ok=True) + for release in releases: + (notes / f"{release.tag}.md").write_bytes(release.notes.encode("utf-8")) + Path(args.out).write_bytes(json.dumps({"releases": [release.to_json() for release in releases]}, indent=2).encode("utf-8")) + lines = [f"- {release.tag}{' (pre-release)' if release.prerelease else ''}" for release in releases] + summary(["### Releases of this merge"] + (lines or ["- none, every version is tagged already"])) + finish(problems, "Release plan") + + def cmd_check_branch(args): """The tag commit must be on the plan's release branch.""" _, plan = read_plan(args.plan) @@ -327,6 +350,14 @@ def main(argv=None): command.add_argument("--branch", required=True) command.set_defaults(func=cmd_checkout_tags) + command = commands.add_parser("pending") + command.add_argument("--head", required=True) + command.add_argument("--merge", required=True) + command.add_argument("--branch", required=True) + command.add_argument("--out", required=True) + command.add_argument("--notes", required=True) + command.set_defaults(func=cmd_pending) + command = commands.add_parser("check-branch") command.add_argument("--plan", required=True) command.add_argument("--commit", required=True) diff --git a/.github/release/sources.py b/.github/release/sources.py index 664d8180..f28b3121 100644 --- a/.github/release/sources.py +++ b/.github/release/sources.py @@ -1,11 +1,15 @@ -"""Checks that every version source of the tag's artifacts carries the tag's version.""" +"""Checks that every version source of the tag's artifacts carries the tag's version, and reads the release notes from +the changelogs.""" import re from pathlib import Path +PLACEHOLDER = "XX.XX.XX" + def check_version_sources(repo_root, plan, config): - """Returns problems; empty when every version source and, for final releases, every changelog heading matches.""" + """Returns problems; empty when every version source and, for final releases, every changelog heading matches, with + no '## XX.XX.XX' heading left.""" problems = [] root = Path(repo_root) for artifact in plan.artifacts: @@ -22,7 +26,53 @@ def check_version_sources(repo_root, plan, config): problems.append(f"{source['file']}: version is {match.group(1).strip()}, the tag says {plan.version}") changelog = spec.get("changelog") if changelog and not plan.prerelease: - text = (root / changelog).read_text(encoding="utf-8") - if re.search(rf"^## {re.escape(plan.version)}\s*$", text, re.MULTILINE) is None: + path = root / changelog + if not path.is_file(): + problems.append(f"{changelog} does not exist") + continue + text = path.read_text(encoding="utf-8") + if changelog_section(text, plan.version) is None: problems.append(f"{changelog}: no '## {plan.version}' heading") + if changelog_section(text, PLACEHOLDER) is not None: + problems.append(f"{changelog}: still has a '## {PLACEHOLDER}' heading; move its lines under '## {plan.version}'") return list(dict.fromkeys(problems)) + + +def release_notes(repo_root, plan, config): + """The notes of the plan's GitHub release, the changelog section of its version for each of its artifacts; a + pre-release takes the '## XX.XX.XX' section when there is one. Returns the notes and the problems that leave a + changelog without them.""" + notes = "" + problems = [] + for artifact in plan.artifacts: + changelog = config["artifacts"][artifact.key].get("changelog") + if not changelog: + continue + path = Path(repo_root) / changelog + if not path.is_file(): + problems.append(f"{changelog} does not exist") + continue + text = path.read_text(encoding="utf-8") + section = (changelog_section(text, PLACEHOLDER) if plan.prerelease else None) or changelog_section(text, plan.version) + if section is None: + problems.append(f"{changelog}: no '## {plan.version}' heading") + notes += section or "" + return notes, problems + + +def changelog_section(text, heading): + """The lines under the '## ' heading of a changelog, up to the next '## ' heading outside a fenced code + block, without the blank lines around them; None when the changelog has no such heading.""" + lines = text.splitlines() + start = next((index for index, line in enumerate(lines) if re.fullmatch(rf"## {re.escape(heading)}\s*", line)), None) + if start is None: + return None + section = [] + fenced = False + for line in lines[start + 1:]: + if line.lstrip().startswith("```"): + fenced = not fenced + elif not fenced and line.startswith("## "): + break + section.append(line) + return "\n".join(section).strip("\n") + "\n" diff --git a/.github/release/tests/support.py b/.github/release/tests/support.py index 374169c3..837d03b6 100644 --- a/.github/release/tests/support.py +++ b/.github/release/tests/support.py @@ -1,16 +1,40 @@ -"""Shared test helpers: the repository configuration and in-memory stand-ins for R2 and the web.""" +"""Shared test helpers: the repository configuration, fake checkouts and in-memory stand-ins for R2 and the web.""" import hashlib from pathlib import Path from plan import load_config +BUILD_GRADLE = 'allprojects {\n ext.CLY_VERSION = "{version}"\n ext.POWERMOCK_VERSION = "1.7.4"\n}\n' +CONFIG_JAVA = 'public class Config {\n protected String sdkVersion = "{version}";\n}\n' + def repository_config(): """The real config.json of this repository.""" return load_config(Path(__file__).resolve().parents[1] / "config.json") +def write_file(root, relative, text): + """Writes a file of a fake checkout.""" + path = Path(root) / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(text.encode("utf-8")) + + +def write_core(root, properties_version, gradle_version, java_version, changelog): + """Writes the three version sources of the core SDK and its changelog into a fake checkout.""" + write_file(root, "gradle.properties", f"VERSION_NAME={properties_version}\nGROUP=ly.count.sdk\n") + write_file(root, "build.gradle", BUILD_GRADLE.replace("{version}", gradle_version)) + write_file(root, "sdk-java/src/main/java/ly/count/sdk/java/Config.java", CONFIG_JAVA.replace("{version}", java_version)) + write_file(root, "CHANGELOG.md", changelog) + + +def write_ui(root, version, changelog): + """Writes the version source of the UI package and its changelog into a fake checkout.""" + write_file(root, "sdk-java-ui/gradle.properties", f"POM_ARTIFACT_ID=java-ui\nVERSION_NAME={version}\n") + write_file(root, "sdk-java-ui/CHANGELOG.md", changelog) + + class FakeBucket: """In-memory stand-in for s3.R2Bucket with MD5 ETags; `fail_on` makes one upload fail like a lost connection.""" diff --git a/.github/release/tests/test_pending.py b/.github/release/tests/test_pending.py new file mode 100644 index 00000000..8bf2bd51 --- /dev/null +++ b/.github/release/tests/test_pending.py @@ -0,0 +1,86 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path + +from pending import merge_problems, pending_releases +from plan import PlanError +from tests.support import repository_config, write_core, write_ui + +HEAD = "a" * 40 +MERGE = "b" * 40 +RELEASED = {"26.8.0", "ui-26.8.0"} + + +def git_runner(contains_head, trees): + """A stand-in for subprocess.run answering the two git questions of merge_problems: whether the merge contains the + head, and the tree of each commit.""" + def run(args, **kwargs): + if "merge-base" in args: + return subprocess.CompletedProcess(args, 0 if contains_head else 1) + return subprocess.CompletedProcess(args, 0, stdout="".join(trees[arg.split("^")[0]] + "\n" for arg in args[-2:])) + return run + + +class PendingTest(unittest.TestCase): + def setUp(self): + self.config = repository_config() + self.root = Path(tempfile.mkdtemp()) + + def checkout(self, core, ui, changelog, ui_changelog, gradle=None): + """Writes the version sources and the changelogs of the core and the UI.""" + write_core(self.root, core, gradle or core, core, changelog) + write_ui(self.root, ui, ui_changelog) + + def test_new_versions_are_released_core_first_with_their_changelog_sections_and_only_the_core_is_latest(self): + self.checkout("26.9.0", "26.9.0", "## 26.9.0\n\n* Core change.\n with a second line\n\n## 26.8.0\n* Old.\n", "## 26.9.0\n* UI change.\n") + releases, problems = pending_releases(self.config, self.root, "staging", RELEASED) + self.assertEqual(problems, []) + self.assertEqual([(release.tag, release.latest, release.prerelease) for release in releases], [("26.9.0", True, False), ("ui-26.9.0", False, False)]) + self.assertEqual([release.notes for release in releases], ["* Core change.\n with a second line\n", "* UI change.\n"]) + self.assertEqual(releases[1].to_json(), {"tag": "ui-26.9.0", "prerelease": False, "latest": False}) + + def test_tagged_versions_are_skipped_so_an_unchanged_ui_or_a_repeated_merge_releases_nothing_more(self): + self.checkout("26.9.0", "26.8.0", "## 26.9.0\n* Core change.\n", "## 26.8.0\n* Old UI.\n") + releases, problems = pending_releases(self.config, self.root, "staging", RELEASED) + self.assertEqual(([release.tag for release in releases], problems), (["26.9.0"], [])) + self.assertEqual(pending_releases(self.config, self.root, "staging", RELEASED | {"26.9.0"}), ([], [])) + + def test_a_changelog_or_a_version_source_not_ready_is_reported_for_every_release(self): + self.checkout("26.9.0", "26.9.0", "## XX.XX.XX\n* Still waiting.\n\n## 26.9.0\n* Core change.\n", "## 26.8.0\n", gradle="26.8.0") + _, problems = pending_releases(self.config, self.root, "staging", RELEASED) + self.assertEqual(problems, [ + "build.gradle: version is 26.8.0, the tag says 26.9.0", + "CHANGELOG.md: still has a '## XX.XX.XX' heading; move its lines under '## 26.9.0'", + "sdk-java-ui/CHANGELOG.md: no '## 26.9.0' heading", + ]) + + def test_a_release_candidate_is_a_prerelease_never_latest_with_the_placeholder_section_as_notes(self): + self.checkout("26.9.0-rc1", "26.8.0", "## XX.XX.XX\n* Coming soon.\n\n## 26.8.0\n* Old.\n", "## 26.8.0\n") + releases, problems = pending_releases(self.config, self.root, "staging", RELEASED) + self.assertEqual(problems, []) + self.assertEqual([(release.tag, release.latest, release.prerelease, release.notes) for release in releases], [("26.9.0-rc1", False, True, "* Coming soon.\n")]) + + def test_a_release_candidate_falls_back_to_its_version_section_and_needs_one_of_them(self): + self.checkout("26.9.0-rc1", "26.8.0", "## 26.9.0-rc1\n* Ready early.\n", "## 26.8.0\n") + releases, problems = pending_releases(self.config, self.root, "staging", RELEASED) + self.assertEqual(([release.notes for release in releases], problems), (["* Ready early.\n"], [])) + self.checkout("26.9.0-rc1", "26.8.0", "## 26.8.0\n* Old.\n", "## 26.8.0\n") + _, problems = pending_releases(self.config, self.root, "staging", RELEASED) + self.assertEqual(problems, ["CHANGELOG.md: no '## 26.9.0-rc1' heading"]) + + def test_a_version_outside_the_tag_grammar_stops_the_plan(self): + self.checkout("XX.XX.XX", "26.8.0", "## XX.XX.XX\n", "## 26.8.0\n") + with self.assertRaises(PlanError): + pending_releases(self.config, self.root, "staging", RELEASED) + + def test_the_merge_must_contain_the_head_and_hold_exactly_its_tree(self): + self.assertEqual(merge_problems(HEAD, MERGE, "/repo", runner=git_runner(True, {HEAD: "tree", MERGE: "tree"})), []) + self.assertEqual(merge_problems(HEAD, MERGE, "/repo", runner=git_runner(False, {})), + [f"{MERGE} does not contain {HEAD}; merge the release PR with a merge commit, not a squash or a rebase"]) + self.assertEqual(merge_problems(HEAD, MERGE, "/repo", runner=git_runner(True, {HEAD: "tree", MERGE: "other"})), + ["master holds changes staging lacks; merge master into staging, then update the release PR"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/release/tests/test_release_cli.py b/.github/release/tests/test_release_cli.py index 01df1bfd..29852036 100644 --- a/.github/release/tests/test_release_cli.py +++ b/.github/release/tests/test_release_cli.py @@ -1,18 +1,21 @@ import json import os +import shutil import subprocess import sys import tempfile import unittest from pathlib import Path +from tests.support import write_core, write_ui + RELEASE = Path(__file__).resolve().parents[1] / "release.py" -def run_cli(*args, output=""): +def run_cli(*args, output="", script=RELEASE): """Runs release.py like the workflow does; GitHub's step output file is written only when `output` names one.""" env = dict(os.environ, GITHUB_OUTPUT=output, GITHUB_STEP_SUMMARY="") - return subprocess.run([sys.executable, str(RELEASE), *args], capture_output=True, text=True, env=env) + return subprocess.run([sys.executable, str(script), *args], capture_output=True, text=True, env=env) class ReleaseCliTest(unittest.TestCase): @@ -61,6 +64,32 @@ def test_a_branch_without_releases_gets_the_empty_list_the_workflow_skips_on(sel self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(output.read_text(encoding="utf-8").strip(), "tags=[]") + def test_pending_creates_nothing_when_the_merge_does_not_contain_the_head(self): + out = self.folder / "pending.json" + result = run_cli("pending", "--head", "0" * 40, "--merge", "HEAD", "--branch", "staging", "--out", str(out), "--notes", str(self.folder / "notes")) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("::error::", result.stdout) + self.assertFalse(out.exists()) + + def test_pending_writes_the_untagged_releases_and_their_notes_for_the_workflow(self): + """Runs a copy of the release tools in a throwaway repository whose core version has no tag yet.""" + repo = self.folder / "repo" + shutil.copytree(RELEASE.parent, repo / ".github/release", ignore=shutil.ignore_patterns("tests", "__pycache__")) + write_core(repo, "26.9.0", "26.9.0", "26.9.0", "## 26.9.0\n* Core change.\n\n## 26.8.0\n* Old.\n") + write_ui(repo, "26.8.0", "## 26.8.0\n* Old UI.\n") + git = ["git", "-C", str(repo), "-c", "user.name=Release test", "-c", "user.email=release@example.com", "-c", "commit.gpgsign=false", "-c", "tag.gpgSign=false"] + subprocess.run(["git", "init", "-q", str(repo)], check=True) + subprocess.run(git + ["add", "-A"], check=True) + subprocess.run(git + ["commit", "-q", "-m", "Release 26.9.0"], check=True) + subprocess.run(git + ["tag", "ui-26.8.0"], check=True) + out = self.folder / "pending.json" + notes = self.folder / "notes" + result = run_cli("pending", "--head", "HEAD", "--merge", "HEAD", "--branch", "staging", "--out", str(out), "--notes", str(notes), script=repo / ".github/release/release.py") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(json.loads(out.read_text(encoding="utf-8")), {"releases": [{"tag": "26.9.0", "prerelease": False, "latest": True}]}) + self.assertEqual(sorted(path.name for path in notes.iterdir()), ["26.9.0.md"]) + self.assertEqual((notes / "26.9.0.md").read_text(encoding="utf-8"), "* Core change.\n") + def test_prerelease_flag_must_match(self): cases = [("26.8.1", "true", 1), ("26.8.1-rc1", "false", 1), ("26.8.1", "unknown", 1), ("26.8.1-rc1", "true", 0), ("26.8.1", "false", 0)] for tag, prerelease, code in cases: diff --git a/.github/release/tests/test_sources.py b/.github/release/tests/test_sources.py index a8c8f243..b8b37b82 100644 --- a/.github/release/tests/test_sources.py +++ b/.github/release/tests/test_sources.py @@ -4,11 +4,8 @@ from pathlib import Path from plan import plan_for_tag -from sources import check_version_sources -from tests.support import repository_config - -BUILD_GRADLE = 'allprojects {\n ext.CLY_VERSION = "{version}"\n ext.POWERMOCK_VERSION = "1.7.4"\n}\n' -CONFIG_JAVA = 'public class Config {\n protected String sdkVersion = "{version}";\n}\n' +from sources import changelog_section, check_version_sources +from tests.support import repository_config, write_core, write_file class SourcesTest(unittest.TestCase): @@ -18,16 +15,11 @@ def setUp(self): def write(self, relative, text): """Writes a file of the fake checkout.""" - path = self.root / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(text.encode("utf-8")) + write_file(self.root, relative, text) def write_sdk(self, properties_version, gradle_version, java_version, changelog): """Writes the three version sources and the changelog.""" - self.write("gradle.properties", f"VERSION_NAME={properties_version}\nGROUP=ly.count.sdk\n") - self.write("build.gradle", BUILD_GRADLE.replace("{version}", gradle_version)) - self.write("sdk-java/src/main/java/ly/count/sdk/java/Config.java", CONFIG_JAVA.replace("{version}", java_version)) - self.write("CHANGELOG.md", changelog) + write_core(self.root, properties_version, gradle_version, java_version, changelog) def test_matching_sources(self): self.write_sdk("26.8.1", "26.8.1", "26.8.1", "## 26.8.1\n* Fixed things.\n") @@ -40,6 +32,7 @@ def test_mismatches_are_reported_once(self): "build.gradle: version is 26.8.0, the tag says 26.8.1", "sdk-java/src/main/java/ly/count/sdk/java/Config.java: version is 26.8.2, the tag says 26.8.1", "CHANGELOG.md: no '## 26.8.1' heading", + "CHANGELOG.md: still has a '## XX.XX.XX' heading; move its lines under '## 26.8.1'", ]) def test_candidates_need_no_changelog_heading(self): @@ -65,6 +58,19 @@ def test_ui_mismatches_are_reported(self): "sdk-java-ui/CHANGELOG.md: no '## 26.8.1' heading", ]) + def test_a_changelog_section_ends_at_the_next_heading_and_keeps_its_lines(self): + text = "## XX.XX.XX\n\n* New.\n ```groovy\n code\n ```\n\n## 26.8.0\n* Old.\n" + self.assertEqual(changelog_section(text, "XX.XX.XX"), "* New.\n ```groovy\n code\n ```\n") + self.assertEqual(changelog_section(text, "26.8.0"), "* Old.\n") + self.assertIsNone(changelog_section(text, "26.9.0")) + fenced = "## 26.9.0\r\n* Add the repository:\r\n```markdown\r\n## Not a release\r\n```\r\n## 26.8.0\r\n" + self.assertEqual(changelog_section(fenced, "26.9.0"), "* Add the repository:\n```markdown\n## Not a release\n```\n") + + def test_a_missing_changelog_is_reported(self): + self.write_sdk("26.9.0", "26.9.0", "26.9.0", "") + (self.root / "CHANGELOG.md").unlink() + self.assertEqual(check_version_sources(self.root, plan_for_tag(self.config, "26.9.0"), self.config), ["CHANGELOG.md does not exist"]) + def test_every_version_source_is_found_in_this_checkout(self): root = Path(__file__).resolve().parents[3] for spec in self.config["artifacts"].values(): diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml new file mode 100644 index 00000000..b6103e3a --- /dev/null +++ b/.github/workflows/release-on-merge.yml @@ -0,0 +1,84 @@ +name: Release on merge + +on: + pull_request: + types: [opened, reopened, synchronize, edited, labeled, unlabeled, closed] + branches: [master] + +permissions: {} + +jobs: + check: + name: Release plan + if: >- + github.event.action != 'closed' + && contains(github.event.pull_request.labels.*.name, 'release') + && github.event.pull_request.head.ref == 'staging' + && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Plan the releases of the merge + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: python3 .github/release/release.py pending --head "$HEAD_SHA" --merge "$GITHUB_SHA" --branch staging --out pending.json --notes notes + - name: Releasing on merge is off + if: vars.RELEASE_ON_MERGE != 'true' + run: | + echo "::warning::The RELEASE_ON_MERGE variable is not true, so merging this PR creates no release; publish the GitHub release by hand." + echo "- Releasing on merge is off: merging creates no release." >> "$GITHUB_STEP_SUMMARY" + + release: + name: Create the releases + if: >- + github.event.action == 'closed' + && github.event.pull_request.merged + && contains(github.event.pull_request.labels.*.name, 'release') + && github.event.pull_request.head.ref == 'staging' + && github.event.pull_request.head.repo.full_name == github.repository + && vars.RELEASE_ON_MERGE == 'true' + runs-on: ubuntu-24.04 + concurrency: + group: release-on-merge + cancel-in-progress: false + permissions: + contents: write + actions: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + persist-credentials: false + - name: Plan the releases of the merge + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: python3 .github/release/release.py pending --head "$HEAD_SHA" --merge "$MERGE_SHA" --branch staging --out pending.json --notes notes + # A release created with this job's token starts no workflow, so each publish is dispatched on its tag. + - name: Create each GitHub release and start its publish + id: create + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + MERGED_BY: ${{ github.event.pull_request.merged_by.login }} + run: | + jq -r '.releases[] | [.tag, .latest, .prerelease] | @tsv' pending.json | while IFS=$'\t' read -r tag latest prerelease; do + gh release create "$tag" --target "$HEAD_SHA" --title "$tag" --notes-file "notes/$tag.md" --latest="$latest" --prerelease="$prerelease" + gh workflow run release.yml --ref "$tag" -f target=production -f requested_by="$MERGED_BY" + echo "- $tag: GitHub release created, publish started" >> "$GITHUB_STEP_SUMMARY" + done + - name: Recovery + if: failure() && steps.create.outcome == 'failure' + run: | + { + echo "### Some releases may be incomplete" + echo "A GitHub release listed above without its publish has to be published by hand: run the Release workflow on its tag with target production." + echo "A release that is not listed was not created; create it as a GitHub release on the PR's head commit, which starts its publish." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-tools.yml b/.github/workflows/release-tools.yml index ac3aeede..b43897f5 100644 --- a/.github/workflows/release-tools.yml +++ b/.github/workflows/release-tools.yml @@ -33,14 +33,14 @@ jobs: echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c - tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint # actionlint 1.7.12 predates the concurrency queue key GitHub added in May 2026 (rhysd/actionlint#654). - "$RUNNER_TEMP/actionlint" -ignore 'unexpected key "queue" for "concurrency" section' .github/workflows/release.yml .github/workflows/release-tools.yml + "$RUNNER_TEMP/actionlint" -ignore 'unexpected key "queue" for "concurrency" section' .github/workflows/release.yml .github/workflows/release-tools.yml .github/workflows/release-on-merge.yml - name: zizmor run: | curl -sSfL -o "$RUNNER_TEMP/zizmor.tar.gz" https://github.com/zizmorcore/zizmor/releases/download/v1.30.1/zizmor-x86_64-unknown-linux-gnu.tar.gz echo "e65324f4430c2717591937edcec90ccbefaf14c174f8ec9415e03ca875b46e1a $RUNNER_TEMP/zizmor.tar.gz" | sha256sum -c - tar -xzf "$RUNNER_TEMP/zizmor.tar.gz" -C "$RUNNER_TEMP" zizmor # The informational findings are expansions of the plan's Gradle task lists, which come from config.json in the same checkout. - "$RUNNER_TEMP/zizmor" --offline --min-severity low .github/workflows/release.yml .github/workflows/release-tools.yml + "$RUNNER_TEMP/zizmor" --offline --min-severity low .github/workflows/release.yml .github/workflows/release-tools.yml .github/workflows/release-on-merge.yml tags: name: Tags this branch releases diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c2e2d67..d18033cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,10 @@ on: type: choice options: [production, test] default: production + requested_by: + description: "who the announcement names as publishing the release; the one starting the run when empty" + type: string + default: "" permissions: {} @@ -35,6 +39,7 @@ jobs: check_tasks: ${{ steps.plan.outputs.check_tasks }} publish_tasks: ${{ steps.plan.outputs.publish_tasks }} published_modules: ${{ steps.plan.outputs.published_modules }} + prerelease: ${{ steps.release.outputs.prerelease }} steps: - name: Only tags can be released if: github.ref_type != 'tag' @@ -226,11 +231,25 @@ jobs: announce: name: Announce - needs: verify - if: github.event_name == 'release' && !github.event.release.prerelease + needs: [plan, verify] + if: needs.plan.outputs.prerelease == 'false' runs-on: ubuntu-24.04 - permissions: {} + permissions: + contents: read + env: + RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ github.ref_name }} steps: + - name: Read the release notes + id: release + env: + GH_TOKEN: ${{ github.token }} + run: | + delimiter="EOF_$(openssl rand -hex 16)" + { + echo "body<<$delimiter" + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body --jq .body + echo "$delimiter" + } >> "$GITHUB_OUTPUT" - name: Slack uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 with: @@ -240,18 +259,18 @@ jobs: payload: | { "repository": "${{ github.repository }}", - "tag_name": "${{ github.event.release.tag_name }}", - "actor": "${{ github.actor }}", - "body": ${{ toJSON(github.event.release.body) }}, - "html_url": "${{ github.event.release.html_url }}" + "tag_name": "${{ env.TAG }}", + "actor": ${{ toJSON(inputs.requested_by || github.actor) }}, + "body": ${{ toJSON(steps.release.outputs.body) }}, + "html_url": "${{ env.RELEASE_URL }}" } - name: Discord uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0 with: webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} nodetail: true - title: New ${{ github.repository }} version ${{ github.event.release.tag_name }} published by ${{ github.actor }} + title: New ${{ github.repository }} version ${{ env.TAG }} published by ${{ inputs.requested_by || github.actor }} description: | - Release URL: ${{ github.event.release.html_url }} - Click [here](${{ github.server_url }}/${{ github.repository }}/blob/${{ github.event.release.tag_name }}/CHANGELOG.md) to view the change log. - `${{ github.event.release.body }}` + Release URL: ${{ env.RELEASE_URL }} + Click [here](${{ github.server_url }}/${{ github.repository }}/blob/${{ env.TAG }}/CHANGELOG.md) to view the change log. + `${{ steps.release.outputs.body }}`