3612f51812
GitHub's "Latest release" is the newest game release, so new players download the game's archive, which does not start on its own (#1647). Making a launcher release the latest instead would break every installed horizon-server, install.sh and install.ps1: they ask for releases/latest and expect the game's archives, and a server that can't update can't fetch a fix either. So the game stays the latest and says where to go: - The notes open with "Want to play? Download the Horizon launcher, not the files below", linking playhorizon.dev and the launcher releases. It is raw HTML on one line, which GitHub renders and the launcher's notes view and one-line summary skip (pulldown-cmark HTML events), so launchers already installed don't tell players to download themselves. The game footer that said the same at the bottom goes. The site's Releases page leaves the block out. - release.yml gives each archive a label ("Game files the launcher installs: Linux (...)", "Dedicated server for Linux (...)"), which GitHub shows instead of the file name. Everything that downloads matches the asset's name, which is unchanged. Fixes #1647 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
499 lines
26 KiB
Python
499 lines
26 KiB
Python
#!/usr/bin/env python3
|
|
"""Decides when Horizon releases, names the release and writes its notes (.github/workflows/release.yml).
|
|
|
|
python3 tools/release_train.py decide --kind game [--head SHA] [--force]
|
|
python3 tools/release_train.py decide --kind launcher
|
|
python3 tools/release_train.py notes --kind game --tag v2026.10.15 --out dist/notes
|
|
python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json [--blockers] # before publishing
|
|
|
|
A kind releases when a pull request merged into main since its last published release changed one of its
|
|
player-facing paths (GAME_PATHS, LAUNCHER_PATHS), once BATCH of them are waiting and the newest merged at least
|
|
DEBOUNCE ago (a burst of merges becomes one release), or once the oldest has waited MAX_WAIT (a lone fix still ships),
|
|
or at once when one of those pull requests is labelled release-now. Pull requests labelled skip-release (work players don't see) don't count
|
|
towards a release and aren't listed in the notes. RELEASES_PAUSED=1 (a repository variable) stops automatic releases,
|
|
and an open issue labelled release-blocker stops game releases, even forced ones, until it is closed.
|
|
|
|
Tags (docs/versioning.md): with a release line in .github/release-line.txt (MAJOR.MINOR, e.g. 0.2), game releases
|
|
are vMAJOR.MINOR.PATCH and launcher releases launcher@MAJOR.MINOR.PATCH, each the patch after the last tag of its kind
|
|
on the line (MAJOR.MINOR.0 first: a line's first release goes out without waiting for a pull request). Without the
|
|
file, the tags from before 0.2: vYYYY.MM.DD, then .01, .02 the same UTC day, and launcher-vX.Y.Z, the patch after
|
|
the last launcher tag or the next minor when a merged pull request is labelled feature. Every 0.2 tag is newer than
|
|
every tag from before (tag_key). Notes:
|
|
"### New" lists the pull requests labelled feature, "### Fixed" the others, "### Known issues" the bullets of
|
|
docs/known-issues.md. Every release is published as the release, GitHub's latest, which launchers, servers and
|
|
install.sh take at once; one channel, no promotion. A game release is published only after `gate`: its smoke test
|
|
passed and no release-blocker is open. Notes cover everything since the last published release; the game
|
|
pre-releases of the old Alpha channel (v0.2.1 on) don't count, so the first release after them lists all of
|
|
it since v0.2.0. See docs/release-flow.md.
|
|
|
|
Prints the decision as JSON and writes it to $GITHUB_OUTPUT when set. Needs git history and the gh CLI.
|
|
"""
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
REPO = os.environ.get("GITHUB_REPOSITORY", "ludovic111/horizon")
|
|
# Releases ship in batches, so players get a few releases a day rather than one per merge: BATCH player-facing pull
|
|
# requests, settled for DEBOUNCE, or whatever is waiting once the oldest has waited MAX_WAIT.
|
|
BATCH = 5
|
|
DEBOUNCE = dt.timedelta(minutes=5)
|
|
MAX_WAIT = dt.timedelta(hours=6)
|
|
# Kept as typed in release titles, where the clauses after the first are lowercased.
|
|
PROPER = {"Horizon", "Horizon's", "Minecraft", "Mojang", "Microsoft", "Java", "Xbox", "PlayStation", "Steam", "Windows",
|
|
"Linux", "LAN", "Kira", "Discord", "Wayland", "Hyprland", "F3", "GUI", "UI"}
|
|
EXPORTERS = "mods/horizon/src/test/java/dev/horizonlab/parity/"
|
|
# The release line (docs/versioning.md): MAJOR.MINOR. Without the file, releases keep the tags from before 0.2.
|
|
LINE_FILE = ROOT / ".github" / "release-line.txt"
|
|
SEMVER = r"(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
|
|
# Semver's majors stay below this; a date tag's first number is a year.
|
|
FIRST_YEAR = 1000
|
|
GAME_PREFIX = "v"
|
|
LAUNCHER_PREFIX = "launcher@"
|
|
OLD_LAUNCHER_PREFIX = "launcher-v"
|
|
|
|
# What players run. Documentation (*.md), tests, benchmarks and harness probes (examples/) never count.
|
|
GAME_PATHS = ["crates/", "xtask/", "packaging/", "Cargo.toml", "Cargo.lock", "research-lock.json"]
|
|
# Every crate the launcher builds in (cargo metadata --manifest-path launcher/Cargo.toml: the path packages).
|
|
LAUNCHER_PATHS = [
|
|
"launcher/",
|
|
"crates/horizon-bench/",
|
|
"crates/horizon-friends/",
|
|
"crates/horizon-mods/",
|
|
"crates/horizon-port-pack/",
|
|
"crates/horizon-prepare/",
|
|
"crates/horizon-release-sign/",
|
|
"crates/horizon-telemetry/",
|
|
"crates/horizon-theme/",
|
|
]
|
|
LAUNCHER_IGNORED = ["launcher/links.json", "launcher/docs/", "launcher/tests/"]
|
|
|
|
# A game release is GitHub's latest (servers and install.sh follow it), so it is the first release people land on:
|
|
# its notes open with the way to the launcher (#1647). Raw HTML on one line, which GitHub shows and every launcher's
|
|
# notes view and one-line summary skip (pulldown-cmark's HTML events), so launchers never tell players to get
|
|
# themselves. The site's Releases page leaves it out too.
|
|
PLAYERS = ('<h3>Want to play? Download the <a href="https://playhorizon.dev">Horizon launcher</a>, not the files '
|
|
'below</h3><p>It installs this version and keeps it up to date. It is also on GitHub: <a href="'
|
|
f'https://github.com/{REPO}/releases?q=launcher&expanded=true">the launcher releases</a>. You need your '
|
|
"own copy of Minecraft: Java Edition. The files below are what the launcher installs, and the dedicated "
|
|
"server for people who host one.</p>")
|
|
LAUNCHER_FOOTER = ("Your launcher updates itself. New here? Download the archive for your system below, extract it, "
|
|
"and run `horizon-launcher`. Website: https://playhorizon.dev")
|
|
DISCLAIMER = "Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft."
|
|
# An open issue with this label holds every game release (docs/release-flow.md). Only people who can triage the
|
|
# repository can add it, and the issue's text is never read: its author could edit it afterwards.
|
|
BLOCKER = "release-blocker"
|
|
|
|
|
|
def run(*command, check=True):
|
|
result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True)
|
|
if check and result.returncode:
|
|
raise SystemExit(f"{' '.join(command)}: {result.stderr.strip()}")
|
|
return result.stdout
|
|
|
|
|
|
def gh_json(*arguments):
|
|
return json.loads(run("gh", *arguments) or "null")
|
|
|
|
|
|
def exporters():
|
|
"""The exporter and oracle sources horizon-prepare compiles: those its sources name."""
|
|
names = set()
|
|
for source in (ROOT / "crates" / "horizon-prepare" / "src").glob("*.rs"):
|
|
names.update(re.findall(r'"([A-Z]\w+\.java)"', source.read_text()))
|
|
return {EXPORTERS + name for name in names}
|
|
|
|
|
|
def player_facing(path, kind, exporter_paths=frozenset()):
|
|
"""Whether a change to path ships to players in a release of this kind."""
|
|
if path.endswith(".md") or re.match(r"(crates|launcher)/([\w-]+/)?(examples|tests|benches)/", path):
|
|
return False
|
|
if kind == "launcher":
|
|
return any(path.startswith(p) for p in LAUNCHER_PATHS) and not any(path.startswith(p) for p in LAUNCHER_IGNORED)
|
|
return any(path == p or path.startswith(p) for p in GAME_PATHS) or path in exporter_paths
|
|
|
|
|
|
def semver(tag, prefix):
|
|
"""(major, minor, patch, pre-release or None) of a 0.2 tag `<prefix>X.Y.Z[-PRE]`, else None."""
|
|
match = re.fullmatch(re.escape(prefix) + SEMVER, tag)
|
|
if not match or int(match[1]) >= FIRST_YEAR:
|
|
return None
|
|
return int(match[1]), int(match[2]), int(match[3]), match[4]
|
|
|
|
|
|
def is_game_tag(tag):
|
|
return re.fullmatch(r"v\d{4}\.\d{2}\.\d{2}(\.\d+)?", tag) is not None or semver(tag, GAME_PREFIX) is not None
|
|
|
|
|
|
def is_launcher_tag(tag):
|
|
return re.fullmatch(r"launcher-v\d+\.\d+\.\d+", tag) is not None or semver(tag, LAUNCHER_PREFIX) is not None
|
|
|
|
|
|
def tag_key(tag):
|
|
"""A tag's place among releases (docs/versioning.md): the tags from before 0.2 (vYYYY.MM.DD[.N],
|
|
launcher-vX.Y.Z) by their numbers, trailing zeros dropped (v2026.10.19 is v2026.10.19.0); then every 0.2 tag
|
|
(vX.Y.Z, launcher@X.Y.Z) in semver's order, a pre-release (v0.3.0-rc.1) before its release."""
|
|
version = semver(tag, GAME_PREFIX) or semver(tag, LAUNCHER_PREFIX)
|
|
if version is None:
|
|
numbers = [int(part) for part in re.findall(r"\d+", tag)]
|
|
while numbers and numbers[-1] == 0:
|
|
numbers.pop()
|
|
return 0, tuple(numbers), ()
|
|
*numbers, pre = version
|
|
# A release follows its pre-releases; their identifiers compare one by one, numbers before words.
|
|
rank = (1,) if pre is None else (0, tuple((0, int(i), "") if i.isdigit() else (1, 0, i) for i in pre.split(".")))
|
|
return 1, tuple(numbers), rank
|
|
|
|
|
|
def release_line(path=LINE_FILE):
|
|
"""(major, minor) of the release line, or None without the file (the tags from before 0.2)."""
|
|
if not path.exists():
|
|
return None
|
|
text = path.read_text().strip()
|
|
match = re.fullmatch(r"(\d+)\.(\d+)", text)
|
|
if not match:
|
|
raise SystemExit(f"{path}: a release line is MAJOR.MINOR, like 0.2 (got {text!r})")
|
|
return int(match[1]), int(match[2])
|
|
|
|
|
|
def next_on_line(tags, prefix, line):
|
|
"""<prefix>MAJOR.MINOR.PATCH: the patch after the newest release tagged on the line (drafts and rolled-back
|
|
releases included; a hand-made pre-release such as v0.3.0-rc.1 is not one), or .0. Never a line below one that
|
|
has tags: GitHub's latest release only moves forward."""
|
|
major, minor = line
|
|
versions = [v for v in (semver(tag, prefix) for tag in tags) if v]
|
|
above = [v for v in versions if (v[0], v[1]) > line and v[3] is None]
|
|
if above:
|
|
raise SystemExit(f"{LINE_FILE.name} says {major}.{minor}, but {prefix}{'.'.join(map(str, above[0][:3]))} exists")
|
|
patches = [v[2] for v in versions if (v[0], v[1]) == line and v[3] is None]
|
|
return f"{prefix}{major}.{minor}.{max(patches) + 1 if patches else 0}"
|
|
|
|
|
|
def first_on_line(tags, prefix, line):
|
|
"""Whether no release of this kind is tagged on the line yet (its first goes out at once)."""
|
|
return not any(v and (v[0], v[1]) == line and v[3] is None for v in (semver(tag, prefix) for tag in tags))
|
|
|
|
|
|
def next_game_tag(tags, today, line=None):
|
|
"""vMAJOR.MINOR.PATCH on a release line; before 0.2, vYYYY.MM.DD, or .01, .02... when that day already has a
|
|
tag (drafts and rolled-back releases included).
|
|
|
|
The counter has two digits because GitHub's releases page sorts a day's tags as text: .9 went above .18.
|
|
Every reader compares the numbers as integers, so the unpadded tags from before still order correctly."""
|
|
if line:
|
|
return next_on_line(tags, GAME_PREFIX, line)
|
|
day = f"v{today:%Y.%m.%d}"
|
|
taken = [tag for tag in tags if tag == day or tag.startswith(day + ".")]
|
|
if not taken:
|
|
return day
|
|
return f"{day}.{max(int(tag.split('.')[3]) if tag.count('.') == 3 else 0 for tag in taken) + 1:02d}"
|
|
|
|
|
|
def next_launcher_tag(tags, minor, line=None):
|
|
"""launcher@MAJOR.MINOR.PATCH on a release line (a feature doesn't change the line); before 0.2,
|
|
launcher-vX.Y.Z, the next patch or with minor the next minor."""
|
|
if line:
|
|
return next_on_line(tags, LAUNCHER_PREFIX, line)
|
|
versions = sorted((tuple(int(part) for part in re.findall(r"\d+", tag)) for tag in tags
|
|
if tag.startswith(OLD_LAUNCHER_PREFIX) and is_launcher_tag(tag)), reverse=True)
|
|
major, minor_, patch = versions[0] if versions else (0, 1, 0)
|
|
return f"launcher-v{major}.{minor_ + 1}.0" if minor else f"launcher-v{major}.{minor_}.{patch + 1}"
|
|
|
|
|
|
def merged_pull_requests(base, head):
|
|
"""(number, merge commit) of the pull requests merged into main between base and head, oldest first."""
|
|
found = []
|
|
for line in run("git", "log", "--first-parent", "--reverse", "--format=%H%x09%s", f"{base}..{head}").splitlines():
|
|
commit, _, subject = line.partition("\t")
|
|
match = re.match(r"Merge pull request #(\d+) ", subject) or re.search(r"\(#(\d+)\)$", subject)
|
|
if match:
|
|
found.append((int(match.group(1)), commit))
|
|
return found
|
|
|
|
|
|
def changed_paths(commit):
|
|
"""The files a pull request changed: its merge commit against main before it (first parent)."""
|
|
return run("git", "diff", "--name-only", f"{commit}^1", commit).split()
|
|
|
|
|
|
def pull_request(number):
|
|
data = gh_json("pr", "view", str(number), "--repo", REPO, "--json",
|
|
"number,title,url,labels,closingIssuesReferences,author,mergedAt")
|
|
return {
|
|
"number": data["number"],
|
|
"title": data["title"].strip(),
|
|
"url": data["url"],
|
|
"labels": sorted(label["name"] for label in data["labels"]),
|
|
"issues": sorted(issue["number"] for issue in data.get("closingIssuesReferences") or []),
|
|
"author": (data.get("author") or {}).get("login", ""),
|
|
"merged_at": dt.datetime.fromisoformat(data["mergedAt"].replace("Z", "+00:00")) if data.get("mergedAt") else None,
|
|
}
|
|
|
|
|
|
def published_releases():
|
|
"""Published releases with their tags and dates: no drafts, and no pre-releases (the old Alpha channel's)."""
|
|
releases = gh_json("api", f"repos/{REPO}/releases?per_page=100", "--paginate", "--slurp")
|
|
flat = [release for page in releases for release in page]
|
|
return [release for release in flat if not release["draft"] and not release["prerelease"]]
|
|
|
|
|
|
def open_blockers():
|
|
"""The numbers of the open issues labelled release-blocker (pull requests left out)."""
|
|
issues = gh_json("api", f"repos/{REPO}/issues?labels={BLOCKER}&state=open&per_page=100", "--paginate", "--slurp")
|
|
return sorted(issue["number"] for page in issues for issue in page if "pull_request" not in issue)
|
|
|
|
|
|
def smoke_problem(report):
|
|
"""Why a release smoke test's report (the game's --smoke-report) fails, or None when the game drew a world."""
|
|
if report is None:
|
|
return "no smoke-test.json: the release smoke test did not run"
|
|
if report.get("error") is not None:
|
|
return f"the smoke run failed: {report['error']}"
|
|
if report.get("frames", 0) < 1500 or not report.get("chunks") or not report.get("meshed_sections") \
|
|
or not report.get("player"):
|
|
return "the smoke run did not draw a world"
|
|
return None
|
|
|
|
|
|
def gate(report, blockers):
|
|
"""Why a built game release must not be published, or None: it is published as the latest at once, so its
|
|
smoke test must have passed and no release-blocker may be open."""
|
|
problems = [problem for problem in [smoke_problem(report)] if problem]
|
|
problems += [f"release-blocker #{number} is open" for number in blockers]
|
|
return "; ".join(problems) or None
|
|
|
|
|
|
def last_release(releases, kind):
|
|
keep = is_game_tag if kind == "game" else is_launcher_tag
|
|
ours = [release for release in releases if keep(release["tag_name"])]
|
|
return max(ours, key=lambda release: tag_key(release["tag_name"]), default=None)
|
|
|
|
|
|
def decision(kind, prs, now, paused=False, force=False, new_line=None, blockers=()):
|
|
"""Whether to release now, and why. prs: the merged pull requests since the last release, each with the
|
|
files it changed under "paths" (player-facing ones only) and when it merged ("merged_at"). new_line: the
|
|
release line ("0.2") when it has no release of this kind yet; its first goes out at once. blockers: the open
|
|
release-blocker issues; they hold every release, forced ones too."""
|
|
counted = [pr for pr in prs if pr["paths"] and "skip-release" not in pr["labels"]]
|
|
urgent = [pr for pr in counted if "release-now" in pr["labels"]]
|
|
merged = [pr["merged_at"] for pr in counted if pr.get("merged_at")]
|
|
if blockers:
|
|
return False, "held by " + ", ".join(f"release-blocker #{number}" for number in blockers)
|
|
if force:
|
|
return True, "started by hand with force"
|
|
if paused:
|
|
return False, "RELEASES_PAUSED is set"
|
|
if new_line:
|
|
return True, f"the first release of {new_line}"
|
|
if not counted:
|
|
return False, "no merged pull request changed what players run"
|
|
if urgent:
|
|
return True, "release-now: " + ", ".join(f"#{pr['number']}" for pr in urgent)
|
|
if merged and now - min(merged) >= MAX_WAIT:
|
|
return True, f"{len(counted)} pull requests changed what players run; the oldest has waited {MAX_WAIT.seconds // 3600} hours"
|
|
if len(counted) < BATCH:
|
|
wait = MAX_WAIT - (now - min(merged)) if merged else MAX_WAIT
|
|
return False, (f"{len(counted)} of {BATCH} pull requests: waiting for more, or {wait.seconds // 60 + 1} more "
|
|
"minutes for the oldest")
|
|
if merged and now - max(merged) < DEBOUNCE:
|
|
wait = DEBOUNCE - (now - max(merged))
|
|
return False, f"{len(counted)} pull requests: waiting {wait.seconds // 60 + 1} more minutes for merges to settle"
|
|
return True, f"{len(counted)} pull requests changed what players run"
|
|
|
|
|
|
def clause(title):
|
|
"""The start of a pull request title, for a release's name: "Nether portals: vanilla timing" -> "Nether portals"."""
|
|
title = re.sub(r"^(?:fix|fixes|fixed)\s+(?:the\s+)?", "", title.strip(), flags=re.I)
|
|
return re.split(r"[:;(]| — | - ", title, maxsplit=1)[0].strip().rstrip(".")
|
|
|
|
|
|
def summary(new, fixed, limit=72):
|
|
"""A short name for the release, from its pull request titles, features first."""
|
|
titles = []
|
|
for pr in new[::-1] + fixed[::-1]: # several pull requests of one feature ("Controllers: ...") name it once
|
|
title = clause(pr["title"])
|
|
if title.lower() not in (t.lower() for t in titles):
|
|
titles.append(title)
|
|
shown, text = [], ""
|
|
for title in titles:
|
|
first = title.split(" ", 1)[0]
|
|
if shown and title[1:2].islower() and first not in PROPER:
|
|
title = title[0].lower() + title[1:]
|
|
candidate = ", ".join(shown + [title])
|
|
if shown and len(candidate) > limit - 20:
|
|
break
|
|
shown.append(title)
|
|
text = candidate
|
|
rest = len(titles) - len(shown)
|
|
if rest:
|
|
noun = ("change" if new else "fix") + ("" if rest == 1 else "es" if not new else "s")
|
|
text += f" and {rest} more {noun}"
|
|
return text
|
|
|
|
|
|
def bullet(pr):
|
|
"""A pull request as a line of notes: its title (written for people), its number and the issues it closes."""
|
|
title = re.sub(r"\s*\((?:fixes|closes) #\d+\)|,? stacked on #\d+", "", pr["title"]).strip().rstrip(".")
|
|
issues = "".join(f", fixes #{issue}" for issue in pr["issues"])
|
|
return f"- {title} (#{pr['number']}{issues})"
|
|
|
|
|
|
def known_issues(path=ROOT / "docs" / "known-issues.md"):
|
|
if not path.exists():
|
|
return []
|
|
return [line.rstrip() for line in path.read_text().splitlines() if line.startswith("- ")]
|
|
|
|
|
|
def line_intro(kind, version, previous):
|
|
"""The paragraph a line's first release (X.Y.0) opens with; the first after the dated tags says what changed."""
|
|
found = semver(version, GAME_PREFIX if kind == "game" else LAUNCHER_PREFIX)
|
|
if not found or found[2] != 0 or found[3] is not None:
|
|
return None
|
|
line = f"{found[0]}.{found[1]}"
|
|
if previous and semver(previous, GAME_PREFIX if kind == "game" else LAUNCHER_PREFIX):
|
|
return f"The first release of Horizon {line}."
|
|
if kind == "launcher":
|
|
return (f"The launcher is numbered with the game from now on: this is launcher {line}.0, the release after "
|
|
f"launcher {(previous or '').removeprefix(OLD_LAUNCHER_PREFIX) or '0.16'}. Your launcher takes it by itself.")
|
|
before = (previous or "").removeprefix(GAME_PREFIX)
|
|
return (f"Horizon is numbered from now on: this is Horizon {line}.0" + (f", the release after {before}" if before else "")
|
|
+ ". Releases used to be named after the day they came out; from now on they count up from "
|
|
f"{line}.0, and the launcher's too. The launcher installs it by itself once it has updated itself. A server "
|
|
"installed with install.sh that still says it is up to date takes it with "
|
|
f"`horizon-server update --version {version}`.")
|
|
|
|
|
|
def notes(kind, version, prs, issues, previous=None):
|
|
"""(title, body) of a release. previous: the release before it, for a line's first release."""
|
|
shown = [pr for pr in prs if pr["paths"] and "skip-release" not in pr["labels"]]
|
|
new = [pr for pr in shown if "feature" in pr["labels"]]
|
|
fixed = [pr for pr in shown if "feature" not in pr["labels"]]
|
|
if kind == "game":
|
|
title = f"Horizon {version.removeprefix(GAME_PREFIX)}" + (f": {summary(new, fixed)}" if shown else "")
|
|
else:
|
|
title = f"Horizon launcher {version.removeprefix(OLD_LAUNCHER_PREFIX).removeprefix(LAUNCHER_PREFIX)}"
|
|
intro = line_intro(kind, version, previous)
|
|
parts = [PLAYERS] if kind == "game" else []
|
|
if intro:
|
|
parts.append(intro)
|
|
if not shown:
|
|
parts.append("Rebuilt from the latest main.")
|
|
if new:
|
|
parts.append("### New\n" + "\n".join(map(bullet, new)))
|
|
if fixed:
|
|
parts.append("### Fixed\n" + "\n".join(map(bullet, fixed)))
|
|
if issues and kind == "game":
|
|
parts.append("### Known issues\n" + "\n".join(issues))
|
|
contributors = sorted({pr["author"] for pr in shown if pr["author"] and pr["author"] != REPO.split("/")[0]})
|
|
if contributors:
|
|
parts.append("Thanks to " + ", ".join(f"@{name}" for name in contributors) + ".")
|
|
if kind == "launcher":
|
|
parts.append(LAUNCHER_FOOTER)
|
|
parts.append(DISCLAIMER)
|
|
return title, "\n\n".join(parts) + "\n"
|
|
|
|
|
|
def gather(kind, head, releases=None):
|
|
"""The last published release of this kind and the pull requests merged since, with their player-facing
|
|
paths."""
|
|
releases = published_releases() if releases is None else releases
|
|
last = last_release(releases, kind)
|
|
base = last["tag_name"] if last else run("git", "rev-list", "--max-parents=0", head).split()[0]
|
|
exporter_paths = exporters()
|
|
prs = []
|
|
for number, commit in merged_pull_requests(base, head):
|
|
pr = pull_request(number)
|
|
pr["paths"] = [path for path in changed_paths(commit) if player_facing(path, kind, exporter_paths)]
|
|
prs.append(pr)
|
|
return last, prs
|
|
|
|
|
|
def green_main_head():
|
|
"""The newest commit on main whose checks passed: what an automatic release builds."""
|
|
runs = gh_json("run", "list", "--repo", REPO, "--workflow", "checks", "--branch", "main", "--event", "push",
|
|
"--limit", "10", "--json", "headSha,status,conclusion")
|
|
for entry in runs:
|
|
if entry["status"] == "completed":
|
|
return entry["headSha"] if entry["conclusion"] == "success" else None
|
|
return None
|
|
|
|
|
|
def write_outputs(values):
|
|
if os.environ.get("GITHUB_OUTPUT"):
|
|
with open(os.environ["GITHUB_OUTPUT"], "a") as out:
|
|
for name, value in values.items():
|
|
out.write(f"{name}={value}\n")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("command", choices=["decide", "notes", "gate"])
|
|
parser.add_argument("--kind", choices=["game", "launcher"], required=True)
|
|
parser.add_argument("--head", help="the commit to release (default: the newest commit on main whose checks passed)")
|
|
parser.add_argument("--tag", help="notes: the release's tag")
|
|
parser.add_argument("--force", action="store_true", help="release even without player-facing changes, before merges settle or while paused")
|
|
parser.add_argument("--out", type=Path, help="notes: write title.txt and body.md here")
|
|
parser.add_argument("--now", help="the time to decide at (ISO 8601; default now)")
|
|
parser.add_argument("--smoke", type=Path, help="gate: the release smoke test's report (smoke-test.json)")
|
|
parser.add_argument("--blockers", action="store_true", help="gate: also check that no release-blocker is open")
|
|
args = parser.parse_args()
|
|
now = dt.datetime.fromisoformat(args.now) if args.now else dt.datetime.now(dt.timezone.utc)
|
|
|
|
if args.command == "gate":
|
|
report = json.loads(args.smoke.read_text()) if args.smoke and args.smoke.exists() else None
|
|
problem = gate(report, open_blockers() if args.blockers else [])
|
|
line = f"Not publishing: {problem}" if problem else "Ready to publish: the smoke test passed" + (
|
|
" and no release-blocker is open" if args.blockers else "")
|
|
print(json.dumps(report) if report else "", line, sep="\n")
|
|
if os.environ.get("GITHUB_STEP_SUMMARY"):
|
|
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
|
|
summary.write(f"### {line}\n")
|
|
return 1 if problem else 0
|
|
|
|
head = args.head or green_main_head()
|
|
if not head:
|
|
result = {"release": False, "reason": "main's latest checks did not pass"}
|
|
print(json.dumps(result, indent=1))
|
|
write_outputs({"release": "false", "reason": result["reason"]})
|
|
return 0
|
|
head = run("git", "rev-parse", head).strip()
|
|
last, prs = gather(args.kind, head)
|
|
if args.command == "notes":
|
|
version = args.tag or "unreleased"
|
|
previous = last["tag_name"] if last and last["tag_name"] != version else None
|
|
title, body = notes(args.kind, version, prs, known_issues(), previous=previous)
|
|
if args.out:
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
(args.out / "title.txt").write_text(title + "\n")
|
|
(args.out / "body.md").write_text(body)
|
|
print(title + "\n\n" + body)
|
|
return 0
|
|
|
|
tags = run("git", "tag", "--list").split()
|
|
line = release_line()
|
|
prefix = GAME_PREFIX if args.kind == "game" else LAUNCHER_PREFIX
|
|
new_line = f"{line[0]}.{line[1]}" if line and first_on_line(tags, prefix, line) else None
|
|
blockers = open_blockers() if args.kind == "game" else []
|
|
release, reason = decision(args.kind, prs, now, os.environ.get("RELEASES_PAUSED") == "1", args.force, new_line,
|
|
blockers)
|
|
minor = any("feature" in pr["labels"] for pr in prs if pr["paths"])
|
|
tag = next_game_tag(tags, now.date(), line) if args.kind == "game" else next_launcher_tag(tags, minor, line)
|
|
result = {
|
|
"release": release, "reason": reason, "tag": tag, "head": head,
|
|
"last": last["tag_name"] if last else None, "last_published": last["published_at"] if last else None,
|
|
"pull_requests": [{key: pr[key] for key in ("number", "title", "labels", "paths")} for pr in prs],
|
|
}
|
|
print(json.dumps(result, indent=1))
|
|
write_outputs({"release": str(release).lower(), "reason": reason, "tag": tag, "head": head})
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|