From eb78f7acf6d47e5144e58bc9546bb4529d2d9cab Mon Sep 17 00:00:00 2001 From: Jamie Wells Date: Wed, 28 Oct 2026 23:36:06 +0000 Subject: [PATCH 1/2] Announce installer releases in Change Log discussions --- .github/CI.md | 17 ++++ .github/scripts/installer_release.py | 99 +++++++++++++++++++++++ .github/tests/test_installer_release.py | 100 +++++++++++++++++++++++- .github/workflows/installer-release.yml | 1 + 4 files changed, 215 insertions(+), 2 deletions(-) diff --git a/.github/CI.md b/.github/CI.md index 98128444..1efa66a2 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -43,6 +43,23 @@ published as prereleases with the Windows `.exe` attached. Both build workflows share one publication concurrency group, and retrying a build or publication repairs the release for that commit rather than allocating another version. +After the EXE upload and release publication succeed, the same job announces the +release in Discussions → **Change Log** (category slug `change-log`). Each post +includes direct EXE download links, a brief plain-text overview from the merged +PR title, and changed file names with addition/deletion counts and expandable +diff previews. File links open their full PR diffs; the post also links the full +file list and downloadable patch. Binary files or diffs omitted by GitHub have +links instead of previews. Large posts limit previews and direct readers to the +complete file list to stay within GitHub's size limit. GitHub's PR-files API +returns at most 3,000 files; the full diff and patch links remain available. + +The workflow token needs `discussions: write` alongside its existing permissions, +and Discussions must contain the `change-log` category. Retrying publication +updates the announcement identified by its hidden release-tag marker instead of +creating a duplicate. Keep that marker when editing an announcement manually. +An announcement failure fails the job but leaves the uploaded release available; +fix category access or permissions and rerun the job to repair the announcement. + The publishing workflow must be merged into the default branch before GitHub can trigger it. To diagnose publication, open `Publish installer release` in Actions. Missing/expired artifacts, missing merged-PR metadata, or insufficient diff --git a/.github/scripts/installer_release.py b/.github/scripts/installer_release.py index f06b92d4..22dcc2d9 100644 --- a/.github/scripts/installer_release.py +++ b/.github/scripts/installer_release.py @@ -1,11 +1,14 @@ """Publish an already-built installer; never execute downloaded artifacts.""" +import hashlib +import html import json import os from pathlib import Path import re import subprocess import tempfile +from urllib.parse import quote VERSION = re.compile(r"v(\d+)\.(\d)-pre-alpha", re.IGNORECASE) @@ -32,6 +35,101 @@ def next_tag(releases): return f"v{version // 10}.{version % 10}-Pre-Alpha" +def graphql(query, **variables): + result = json.loads(gh("api", "graphql", "-f", f"query={query}", *( + arg for key, value in variables.items() for arg in ("-f", f"{key}={value}") + ))) + if result.get("errors"): + raise RuntimeError(f"Discussion API failed: {result['errors']}") + return result["data"] + + +def discussion_body(repository, tag, pr, installers, files): + root = f"https://github.com/{repository}" + pr_url = f"{root}/pull/{pr['number']}" + # A short plain-text overview from the merged PR; no copied Markdown body. + summary = html.escape(" ".join(pr["title"].split())[:240]) + summary = re.sub(r"([\\`*_{}\[\]<>#|~])", r"\\\1", summary) + lines = [f"", summary, ""] + for installer in installers: + url = f"{root}/releases/download/{quote(tag, safe='')}/{quote(installer.name, safe='')}" + lines.append(f"[Download {installer.name}]({url})") + lines += ["", f"[Release]({root}/releases/tag/{quote(tag, safe='')}) · " + f"[All changed files and full diff]({pr_url}/files) · " + f"[Download patch]({pr_url}.patch)", "", "## Files changed", ""] + # Keep well below GitHub's body limit, even for very large PRs. + for file in files: + name = file["filename"] + anchor = hashlib.sha256(name.encode("utf-8")).hexdigest() + label = html.escape(name).replace("`", "`") + entry = [f"- [`{label}`]({pr_url}/files#diff-{anchor}) — " + f"{file['status']}, +{file['additions']} / -{file['deletions']}"] + if file.get("previous_filename"): + entry.append(" Renamed from: " + html.escape(file["previous_filename"])) + patch = file.get("patch") + if patch: + preview = patch[:1200] + # An HTML pre block prevents source content from closing a Markdown fence. + entry += ["", "
Diff preview", "", + "
" + html.escape(preview) + "
", "", + "See the file link for the complete diff.", "
", ""] + else: + entry += [" Diff preview unavailable (binary or omitted by GitHub); see the file link.", ""] + if len(("\n".join(lines + entry)).encode("utf-8")) > 50000: + lines.append("More files are included in the full diff linked above.") + break + lines.extend(entry) + return "\n".join(lines) + + +def announce(repository, tag, pr, installers): + owner, name = repository.split("/") + data = graphql("""query($owner:String!, $name:String!) { + repository(owner:$owner, name:$name) { + id discussionCategories(first:100) { nodes { id slug } } + } + }""", owner=owner, name=name)["repository"] + categories = [c for c in data["discussionCategories"]["nodes"] if c["slug"] == "change-log"] + if len(categories) != 1: + raise ValueError("The change-log Discussion category is missing or inaccessible.") + category = categories[0]["id"] + marker = f"" + cursor = "" + discussion_id = None + while True: + page = graphql("""query($owner:String!, $name:String!, $category:ID!, $cursor:String) { + repository(owner:$owner, name:$name) { + discussions(first:100, categoryId:$category, after:$cursor) { + nodes { id body } pageInfo { hasNextPage endCursor } + } + } + }""", owner=owner, name=name, category=category, + **({"cursor": cursor} if cursor else {}))["repository"]["discussions"] + matches = [d["id"] for d in page["nodes"] if d["body"].startswith(marker + "\n")] + if matches: + discussion_id = matches[0] + break + if not page["pageInfo"]["hasNextPage"]: + break + cursor = page["pageInfo"]["endCursor"] + files = api_list(f"repos/{repository}/pulls/{pr['number']}/files?per_page=100") + body = discussion_body(repository, tag, pr, installers, files) + title = f"{tag} — Windows installer" + if discussion_id: + result = graphql("""mutation($id:ID!, $title:String!, $body:String!) { + updateDiscussion(input:{discussionId:$id, title:$title, body:$body}) { + discussion { url } + } + }""", id=discussion_id, title=title, body=body)["updateDiscussion"] + else: + result = graphql("""mutation($repo:ID!, $category:ID!, $title:String!, $body:String!) { + createDiscussion(input:{repositoryId:$repo, categoryId:$category, title:$title, body:$body}) { + discussion { url } + } + }""", repo=data["id"], category=category, title=title, body=body)["createDiscussion"] + print(f"Announced {tag}: {result['discussion']['url']}") + + def publish(repository, run, directory): if ( run["conclusion"] != "success" @@ -83,6 +181,7 @@ def publish(repository, run, directory): if not existing or existing[0]["draft"]: gh("release", "edit", tag, "--repo", repository, "--draft=false") print(f"Published {tag}: https://github.com/{repository}/releases/tag/{tag}") + announce(repository, tag, pulls[0], installers) if __name__ == "__main__": diff --git a/.github/tests/test_installer_release.py b/.github/tests/test_installer_release.py index d777db89..36cee23a 100644 --- a/.github/tests/test_installer_release.py +++ b/.github/tests/test_installer_release.py @@ -57,8 +57,12 @@ def gh(*args): with patch.object(release, "api_list", side_effect=[ [self.pr] if pulls is None else pulls, list(releases) - ]), patch.object(release, "gh", side_effect=gh): + ]), patch.object(release, "gh", side_effect=gh), \ + patch.object(release, "announce") as announce: release.publish(REPOSITORY, self.run, self.temp.name) + announce.assert_called_once_with( + REPOSITORY, calls[0][2], self.pr, [self.installer] + ) return calls def test_new_release_uses_exact_pr_body_commit_and_installer(self): @@ -89,11 +93,13 @@ def test_retry_reuses_release_and_repairs_assets(self): def test_upload_failure_leaves_draft_unpublished(self): with patch.object(release, "api_list", side_effect=[[self.pr], []]), \ - patch.object(release, "gh", side_effect=["", RuntimeError("upload failed")]) as gh: + patch.object(release, "gh", side_effect=["", RuntimeError("upload failed")]) as gh, \ + patch.object(release, "announce") as announce: with self.assertRaisesRegex(RuntimeError, "upload failed"): release.publish(REPOSITORY, self.run, self.temp.name) self.assertEqual(gh.call_count, 2) self.assertIn("--draft", gh.call_args_list[0].args) + announce.assert_not_called() def test_untrusted_or_unsuccessful_runs_cannot_publish(self): changes = [ @@ -135,5 +141,95 @@ def test_api_list_reads_all_pages(self): gh.assert_called_once_with("api", "repos/example/engine/releases", "--paginate", "--slurp") +class DiscussionTests(unittest.TestCase): + def setUp(self): + self.pr = {"number": 42, "title": "Fix editor startup"} + self.installers = [Path("RusticGameEngine-Setup-0.1.exe")] + self.file = { + "filename": "Engine/main.rs", "status": "modified", + "additions": 2, "deletions": 1, "patch": "@@ -1 +1 @@\n-old\n+new", + } + self.repository = {"repository": { + "id": "repo", "discussionCategories": { + "nodes": [{"id": "category", "slug": "change-log"}] + }, + }} + + def page(self, nodes=(), more=False, cursor=None): + return {"repository": {"discussions": { + "nodes": list(nodes), "pageInfo": {"hasNextPage": more, "endCursor": cursor} + }}} + + def test_body_includes_download_overview_files_and_diffs(self): + body = release.discussion_body(REPOSITORY, "v0.2-Pre-Alpha", self.pr, + self.installers, [self.file]) + for text in ["Fix editor startup", "/releases/download/v0.2-Pre-Alpha/", + self.installers[0].name, "Engine/main.rs", "+2 / -1", "+new", + "/pull/42/files#diff-", "/pull/42.patch"]: + self.assertIn(text, body) + + def test_large_and_binary_diffs_have_full_diff_links(self): + files = [self.file | {"filename": f"file-{i}", "patch": "x" * 10000} + for i in range(100)] + files.insert(0, self.file | {"filename": "image.png", "patch": None}) + body = release.discussion_body(REPOSITORY, "tag", self.pr, self.installers, files) + self.assertLess(len(body.encode("utf-8")), 51000) + self.assertIn("binary or omitted", body) + self.assertIn("More files", body) + self.assertIn("/pull/42/files", body) + + def test_source_cannot_close_preview_or_inject_html(self): + file = self.file | {"patch": "+\n+```"} + body = release.discussion_body(REPOSITORY, "tag", self.pr, self.installers, [file]) + self.assertNotIn("\n+```"} @@ -191,10 +216,13 @@ def test_create_uses_category_and_retry_updates_existing_on_later_page(self): operation = "createDiscussion" if existing: pages = [self.page(more=True, cursor="next"), self.page([{ - "id": "discussion", "body": "\nold" + "id": "discussion", "body": "\nold", + "labels": {"nodes": []}, }])] operation = "updateDiscussion" - responses = [self.repository, *pages, {operation: {"discussion": {"url": "url"}}}] + responses = [self.repository, *pages, {operation: { + "discussion": {"id": "discussion", "url": "url"} + }}] with patch.object(release, "graphql", side_effect=responses) as graphql, \ patch.object(release, "api_list", return_value=[self.file]) as api: release.announce(REPOSITORY, "tag", self.pr, self.installers) @@ -219,6 +247,49 @@ def test_graphql_reports_api_errors(self): with self.assertRaisesRegex(RuntimeError, "denied"): release.graphql("query {}") + def test_graphql_passes_label_arrays_as_json(self): + def gh(*args): + import json + payload = json.loads(Path(args[-1]).read_text(encoding="utf-8")) + self.assertEqual(payload["variables"], {"labels": ["one", "two"]}) + return '{"data":{}}' + with patch.object(release, "gh", side_effect=gh): + release.graphql("mutation {}", labels=["one", "two"]) + + def test_discussion_labels_copy_and_sync_on_retry(self): + for existing in (False, True): + with self.subTest(existing=existing): + self.pr["labels"] = [{"node_id": "feature"}, {"node_id": "ci"}] + nodes = [{"id": "discussion", "body": "\nold", + "labels": {"nodes": [{"id": "ci"}, {"id": "old"}]}}] if existing else [] + operation = "updateDiscussion" if existing else "createDiscussion" + responses = [self.repository, self.page(nodes), {operation: { + "discussion": {"id": "discussion", "url": "url"} + }}, {}, *([{}] if existing else [])] + with patch.object(release, "graphql", side_effect=responses) as graphql, \ + patch.object(release, "api_list", return_value=[self.file]): + release.announce(REPOSITORY, "tag", self.pr, self.installers) + add = graphql.call_args_list[3] + self.assertIn("addLabelsToLabelable", add.args[0]) + self.assertEqual(add.kwargs, {"id": "discussion", "labels": ( + ["feature"] if existing else ["ci", "feature"] + )}) + if existing: + remove = graphql.call_args_list[4] + self.assertIn("removeLabelsFromLabelable", remove.args[0]) + self.assertEqual(remove.kwargs["labels"], ["old"]) + + def test_empty_pr_labels_remove_old_discussion_labels(self): + responses = [self.repository, self.page([{ + "id": "discussion", "body": "\nold", + "labels": {"nodes": [{"id": "old"}]}, + }]), {"updateDiscussion": {"discussion": {"id": "discussion", "url": "url"}}}, {}] + with patch.object(release, "graphql", side_effect=responses) as graphql, \ + patch.object(release, "api_list", return_value=[]): + release.announce(REPOSITORY, "tag", self.pr, self.installers) + self.assertIn("removeLabelsFromLabelable", graphql.call_args.args[0]) + self.assertEqual(graphql.call_args.kwargs["labels"], ["old"]) + def test_announcement_failure_keeps_release_published_for_retry(self): fixture = InstallerReleaseTests() fixture.setUp()