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 }}`