Files
horizon-airdef/tools/release_train.py
T
Ludovic Marie 9a3237c5ac Release train: a release line (0.2) for vX.Y.Z and launcher@X.Y.Z tags
docs/versioning.md: with .github/release-line.txt (MAJOR.MINOR) the train tags game releases
vMAJOR.MINOR.PATCH and launcher releases launcher@MAJOR.MINOR.PATCH, the patch after the newest
release on the line; the line's first release goes out at once (still not while RELEASES_PAUSED is
set). Without the file nothing changes: vYYYY.MM.DD[.N] and launcher-vX.Y.Z. The file is not added
here; the switch to 0.2 is a separate one-line pull request for the day Ludovic says go.

- tag_key: every 0.2 tag ranks above every dated or launcher-v tag; semver order within, a
  pre-release before its release. release_channels.py uses the same key and tag test, so promotion
  and "latest only moves forward" work across the switch.
- A line's first release says so in its notes; 0.2.0's explains the numbering and how a server
  that says it is up to date takes it (horizon-server update --version v0.2.0).
- launcher.yml strips either prefix for HORIZON_LAUNCHER_VERSION and passes HORIZON_LAUNCHER_TAG,
  which the launcher uses to know its own line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 00:47:35 +02:00

439 lines
22 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 notes --kind game --tag v2026.10.15 --head v2026.10.15 --stable # at promotion
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) and the newest of them merged at least DEBOUNCE ago (a burst
of merges becomes one release), or the oldest at least MAX_WAIT ago (merges that never pause still ship), or 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.
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. Game releases go out as betas (GitHub pre-releases) and tools/release_channels.py promotes
them to stable; a beta's notes cover what changed since the release before it (beta or stable), a promoted
release's (--stable) everything since the last stable one. 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")
DEBOUNCE = dt.timedelta(minutes=15)
MAX_WAIT = dt.timedelta(hours=1)
# 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/"]
FOOTER = {
"game": "Install or update it from the Horizon launcher: it installs new versions by itself. "
"New here? Get the launcher at https://playhorizon.dev. You need your own copy of Minecraft: Java Edition.",
"beta": "This is a beta build: launchers with Beta builds turned on in Settings install it now; everyone gets it "
"once it has been out 6 hours without a release-blocker.",
"launcher": "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."
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(betas=True):
"""Published (not draft) releases with their tags and dates; without betas (pre-releases) when betas is False."""
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 (betas or not release["prerelease"])]
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):
"""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."""
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 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 - max(merged) < DEBOUNCE and now - min(merged) < MAX_WAIT:
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, beta=False, previous=None):
"""(title, body) of a release; a game beta's say it is one. 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 = [intro] if intro else []
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 beta and kind == "game":
parts.append(FOOTER["beta"])
parts.append(FOOTER[kind])
parts.append(DISCLAIMER)
return title, "\n\n".join(parts) + "\n"
def gather(kind, head, releases=None, betas=True):
"""The last published release of this kind (stable only when betas is False) and the pull requests merged
since, with their player-facing paths."""
releases = published_releases(betas) 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"])
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("--stable", action="store_true",
help="notes: for promoting a beta to stable, so since the last stable release, without the beta line")
args = parser.parse_args()
now = dt.datetime.fromisoformat(args.now) if args.now else dt.datetime.now(dt.timezone.utc)
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, betas=not args.stable)
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(), beta=not args.stable, 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
release, reason = decision(args.kind, prs, now, os.environ.get("RELEASES_PAUSED") == "1", args.force, new_line)
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())