Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,46 @@ 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 a comparison of the PR's recorded base commit and
release merge commit; the post also links the full file list and downloadable
patch. Announcements contain no PR links. 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.

To associate the correct documentation update, add a line to the engine PR
description pointing to the affected **published page**, for example:

```text
Documentation: https://rustic-game-engine.pages.dev/docs/guides/gameplay-programming#hot-reload
```

Repeat the line for additional updated pages; duplicate URLs are removed. Use
the canonical documentation site's `/docs/` URLs, including section anchors when
helpful. Publish the documentation update before the release announcement. Do not
put a documentation PR URL in this field. The announcement copies only these
published-page URLs and never copies the PR description. For maintenance changes
without a documentation update, explicitly use `Documentation: none`; no docs
link is added. Missing or invalid mappings fail the announcement with instructions
to fix the merged engine PR's description and rerun publication. The automation
does not guess a matching update by title or choose the latest documentation PR,
and does not verify that the declared page has deployed.

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.
The announcement copies all labels from the merged engine PR. Reruns synchronize
the Discussion's labels to the PR's current labels, adding missing labels and
removing labels no longer present on the PR (including manually added Discussion
labels). Label failures fail the job and can be repaired with a rerun.
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
Expand Down
133 changes: 133 additions & 0 deletions .github/scripts/installer_release.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
"""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)
DOCS_ROOT = "https://rustic-game-engine.pages.dev/docs/"


def documentation_links(pr):
"""Require an explicit mapping to published pages, never infer a docs PR."""
entries = re.findall(r"^Documentation:[ \t]*(.+)$", pr.get("body") or "", re.MULTILINE)
if not entries:
raise ValueError("Add Documentation: <published docs URL> to the merged PR description "
"(or Documentation: none for changes without a documentation update).")
if len(entries) == 1 and entries[0].strip().lower() == "none":
return []
urls = []
for entry in entries:
found = re.findall(re.escape(DOCS_ROOT) + r"[A-Za-z0-9_./%~-]+(?:#[A-Za-z0-9_.%~-]+)?", entry)
if not found:
raise ValueError("Documentation must link to a published page under " + DOCS_ROOT)
urls.extend(found)
return list(dict.fromkeys(urls))


def gh(*args):
Expand All @@ -32,6 +53,117 @@ def next_tag(releases):
return f"v{version // 10}.{version % 10}-Pre-Alpha"


def graphql(query, **variables):
with tempfile.TemporaryDirectory() as temp:
payload = Path(temp) / "graphql.json"
payload.write_text(json.dumps({"query": query, "variables": variables}), encoding="utf-8")
result = json.loads(gh("api", "graphql", "--input", str(payload)))
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}"
compare_url = f"{root}/compare/{pr['base']['sha']}...{pr['merge_commit_sha']}"
# 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"<!-- installer-release:{tag} -->", summary, ""]
for installer in installers:
url = f"{root}/releases/download/{quote(tag, safe='')}/{quote(installer.name, safe='')}"
lines.append(f"[Download {installer.name}]({url})")
for url in documentation_links(pr):
lines.append(f"[Updated documentation]({url})")
lines += ["", f"[Release]({root}/releases/tag/{quote(tag, safe='')}) · "
f"[All changed files and full diff]({compare_url}) · "
f"[Download patch]({compare_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("`", "&#96;")
entry = [f"- [`{label}`]({compare_url}#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 += ["", "<details><summary>Diff preview</summary>", "",
"<pre>" + html.escape(preview) + "</pre>", "",
"See the file link for the complete diff.", "</details>", ""]
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"<!-- installer-release:{tag} -->"
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 labels(first:100) { nodes { id } } }
pageInfo { hasNextPage endCursor }
}
}
}""", owner=owner, name=name, category=category,
**({"cursor": cursor} if cursor else {}))["repository"]["discussions"]
matches = [d for d in page["nodes"] if d["body"].startswith(marker + "\n")]
if matches:
discussion_id = matches[0]["id"]
existing_labels = {label["id"] for label in matches[0]["labels"]["nodes"]}
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 { id 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 { id url }
}
}""", repo=data["id"], category=category, title=title, body=body)["createDiscussion"]
discussion = result["discussion"]
wanted_labels = {label["node_id"] for label in pr.get("labels", [])}
if not discussion_id:
existing_labels = set()
# Apply additions first so a failed retry retains labels already attached.
for operation, labels in (("addLabelsToLabelable", wanted_labels - existing_labels),
("removeLabelsFromLabelable", existing_labels - wanted_labels)):
if labels:
graphql(f"""mutation($id:ID!, $labels:[ID!]!) {{
{operation}(input:{{labelableId:$id, labelIds:$labels}}) {{ clientMutationId }}
}}""", id=discussion["id"], labels=sorted(labels))
print(f"Announced {tag}: {discussion['url']}")


def publish(repository, run, directory):
if (
run["conclusion"] != "success"
Expand Down Expand Up @@ -83,6 +215,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__":
Expand Down
Loading
Loading