Releases: one channel, every release is the latest once its checks pass

Ludovic's decision (2026-10-23): no more Alpha and Stable. release.yml
publishes every game release as the release and GitHub's latest instead of
a pre-release that promote.yml made stable once a day. The checks that
gated a promotion run in the release itself: `decide` holds the train while
an issue labelled release-blocker is open (forced runs too), and the publish
job runs `release_train.py gate` right before publishing: the Mac mini's
smoke-test.json must be there and show a world was drawn, and no blocker may
be open. Otherwise nothing is published.

Notes cover everything since the last release that is not a pre-release, so
the first single-channel release lists all of it since v0.2.0. promote.yml,
tools/release_channels.py and its tests are deleted. The cadence is
unchanged (5 minutes of settling, 20 at most, release-now at once).

Docs and site: one channel in release-flow, versioning, security, the FAQ,
install, the home page and the Releases page (Game / Launcher filter); no
player-facing Alpha wording left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-23 11:53:00 +02:00
1 parent 62a6b3a620
commit 0e65e85878
21 files changed
+208 -579

No files matched your search

-1
View File
@@ -6,7 +6,6 @@
/.github/ @ludovic111
/tools/ci/ @ludovic111
/tools/release_train.py @ludovic111
/tools/release_channels.py @ludovic111
/crates/horizon-release-sign/ @ludovic111
/crates/horizon-server/src/update.rs @ludovic111
/crates/horizon-server/Cargo.toml @ludovic111
-55
View File
@@ -1,55 +0,0 @@
name: promote
# Beta game releases become stable once a day (docs/release-flow.md, "Channels"): at 20:00 UTC (21:00 in Paris in
# winter time, 22:00 in summer) tools/release_channels.py promotes the newest beta that had been out 6 hours by then,
# passed its release smoke test and isn't covered by an issue labelled release-blocker. Launchers on the Alpha
# channel install every beta when it is published; Stable gets the day's promotion. The later runs of the evening are
# retries for a run GitHub starts late or skips: they promote the same beta, so stable still moves once.
# By hand, from GitHub's app too (Actions > promote > Run workflow):
# gh workflow run promote -f tag=v0.2.3 # that beta now, without waiting for 20:00
# gh workflow run promote -f tag=v0.2.3 -f force=true # even blocked or without its smoke test
# gh workflow run promote -f dry_run=true # say what would be promoted
# Holding betas back: label an issue release-blocker. While it is open it holds every beta; closed as completed it
# still holds the betas published before it closed. The issue's text is never read (its author could edit it).
# When the oldest waiting beta is 30 hours old (it missed a promotion it was due for), an issue "Promotion to stable
# has stalled" says so; it closes itself after the next promotion.
on:
schedule:
- cron: '7,37 20-21 * * *'
workflow_dispatch:
inputs:
tag:
description: The beta to promote now (empty = the newest beta that is due)
type: string
default: ''
force:
description: Promote that tag even if a release-blocker covers it or its smoke test didn't run
type: boolean
default: false
dry_run:
description: Say what would be promoted, change nothing
type: boolean
default: false
permissions:
contents: write
issues: write
concurrency:
group: promote
cancel-in-progress: false
jobs:
promote:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
# The notes of a promoted release cover every pull request since the last stable one.
fetch-depth: 0
# gh uses GH_TOKEN below; nothing needs git credentials on disk.
persist-credentials: false
- name: Promote
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
FORCE: ${{ inputs.force && 'yes' || '' }}
DRY_RUN: ${{ (inputs.dry_run || github.ref != 'refs/heads/main') && 'yes' || '' }}
run: python3 tools/release_channels.py promote ${TAG:+--tag "$TAG"} ${FORCE:+--force} ${DRY_RUN:+--dry-run}
+25 -10
View File
@@ -3,12 +3,15 @@ name: release
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago (the oldest 20 minutes ago, if merges never pause), or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them.
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
# stable in its daily promotion. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
# MACOS_SIGN_IDENTITY set, the programs are signed with
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
# (packaging/macos/README.md); unset (forks), ad hoc.
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
@@ -32,6 +35,8 @@ on:
default: false
permissions:
contents: read
# The open release-blocker issues (decide).
issues: read
concurrency:
group: game-release
cancel-in-progress: false
@@ -203,7 +208,7 @@ jobs:
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
@@ -319,6 +324,7 @@ jobs:
timeout-minutes: 15
permissions:
contents: write
issues: read
steps:
- uses: actions/checkout@v4
with:
@@ -341,6 +347,17 @@ jobs:
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test passed, no release-blocker is open)
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
# reports what a real run would do.
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
run: |
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
echo "::warning::A real run would publish nothing"
fi
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
@@ -348,17 +365,15 @@ jobs:
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
smoke=(); [[ -f dist/smoke-test.json ]] && smoke=(dist/smoke-test.json)
# A beta (pre-release), the Alpha channel's: promote.yml makes it stable and latest in its daily promotion
# (docs/release-flow.md).
# The release and the latest at once: there is one channel (docs/release-flow.md).
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --prerelease --latest=false dist/horizon-linux-x86_64.tar.gz \
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
+41 -66
View File
@@ -2,9 +2,8 @@
How a change gets from a pull request to players: the checks a pull request passes, how it merges, and the
automatic releases that follow. Releases are automatic and frequent so the latest Horizon is always playable.
Each one goes out as an **alpha** build, which launchers on the **Alpha** channel install at once, and becomes **stable** in
the next daily promotion unless something holds it back ([Channels](#channels)): launchers and servers install
stable builds unless their owner picks Alpha.
There is one channel ([One channel](#one-channel)): each release is published as the release and GitHub's *latest*
once its smoke test passed and no `release-blocker` is open, and launchers and servers install it at once.
## Pull request checks
@@ -101,12 +100,11 @@ flowchart LR
windows["Windows x64<br/>GitHub windows-latest"]
mac["macOS arm64<br/>Mac mini runner<br/>+ offscreen game run"]
end
build --> publish["publish vX.Y.Z as an alpha<br/>notes from the merged PRs"]
publish --> alpha(["launchers on Alpha install it"])
publish --> promote{"promote.yml, daily at 20:00 UTC:<br/>6 h old by then, smoke test, no release-blocker"}
promote --> stable["stable and latest<br/>notes since the last stable"]
stable --> launcher(["every launcher and server installs it"])
stable --> kira(["Kira posts it;<br/>pings only with ### New"])
build --> gate{"gate: the smoke test drew a world,<br/>no release-blocker is open"}
gate -- no --> nothing(["nothing is published"])
gate -- yes --> publish["publish vX.Y.Z as the release and latest<br/>notes from the merged PRs"]
publish --> launcher(["every launcher and server installs it"])
publish --> kira(["Kira posts it; the first release<br/>after 20:00 UTC pings, once a day"])
```
### When it releases
@@ -121,9 +119,10 @@ commits) since the last **published** game release:
- It releases when at least one pull request counts and the newest of them merged at least 5 minutes ago, so a wave
of merges becomes one release; if merges never pause, it releases once the oldest has waited 20 minutes. There is
no minimum time between releases: every settled player-facing change ships, limited only by the release already
building. That is the Alpha channel, many releases a day.
building: many releases a day.
- A pull request labelled `release-now` (a crash or a blocker) releases at once.
- A pull request labelled `skip-release` (work players don't see) doesn't count and isn't listed in the notes.
- An open issue labelled `release-blocker` holds every game release, forced ones too (`held by release-blocker #N`).
- One release at a time (concurrency group `game-release`); a run that finds nothing new does nothing.
- A scheduled or hand-started run releases main's newest commit whose `checks` passed; after `checks`, the commit
they passed on.
@@ -137,7 +136,7 @@ How releases are numbered, and how installs from before 0.2 reach it: [versionin
the patch after the newest release tagged on that line (`v0.2.0` first). A `feature` label doesn't change the line:
only that file does, by pull request.
- Without the file (every release before 0.2), `vYYYY.MM.DD`, then `.01`, `.02`… the same UTC day.
- Every reader (`release_train.py`, `release_channels.py`, the launcher, `horizon-server update`, the website) ranks
- Every reader (`release_train.py`, the launcher, `horizon-server update`, the website) ranks
every `vX.Y.Z` tag above every dated one.
### Labels
@@ -149,7 +148,7 @@ How releases are numbered, and how installs from before 0.2 reach it: [versionin
| `skip-release` | Doesn't trigger a release and isn't listed in the notes |
| `run-parity` | Runs the parity job once on a fork's pull request |
| `parity-regression` | Opened by the nightly audit for a harness that fails on main |
| `release-blocker` | On an issue: holds every beta back from stable while it is open ([Channels](#channels)) |
| `release-blocker` | On an issue: no game release is published while it is open ([One channel](#one-channel)) |
Pull requests without `feature` are listed under **Fixed**. Titles are written for players, since they become
the notes.
@@ -180,63 +179,38 @@ runs the game: GitHub's runners have neither a GPU worth the name nor the game f
- `### Known issues`: the bullets of [docs/known-issues.md](known-issues.md);
- thanks to outside contributors, how to install, the disclaimer.
Kira posts every release in Discord's #releases. An Alpha build (a game pre-release) is posted silently: no ping and
Discord's @silent flag, so nobody gets a notification. When the same release becomes Stable it is posted again, and
that post pings the releases role when the notes have a `### New` section (at most once every 6 hours). Launcher
releases post without a ping.
Kira posts every release once in Discord's #releases. It pings the releases role at most once a day: the first game
release published after 20:00 UTC, with a short list of what came out since its last ping. Every other post is
silent (no ping and Discord's @silent flag). Launcher releases post without a ping.
### Channels
### One channel
Every game release is published as an **alpha** build, a GitHub pre-release (the workflows, tools and labels
call it a *beta*: `release-blocker` holds every beta), so GitHub's *latest* release stays the last
**stable** one. The launcher's Settings has an **Updates** choice of two channels (`update_channel` in its
`settings.json`, `horizon-launcher update-channel`):
Since 2026-10-23 there is one channel. Every game release is published as the release, never a pre-release, and
marked GitHub's *latest*: the launcher, `horizon-server update` and `install.sh` take it at once. Because nothing
promotes it later, the checks that used to gate a promotion run in the release itself, and the publish job
(`tools/release_train.py gate`) publishes nothing unless both pass:
- **Stable**, the default: a tested build about once a day, the alpha builds promoted below.
- **Alpha**: every alpha build as soon as it is built, many times a day; it can break. "Alpha" is the project's
word for builds that are still being tested (the whole of Horizon is in beta); Stable is the tested ones. An open
launcher looks for releases every 30 minutes on Alpha (4 hours on Stable). Launchers from before 0.2 had a "Beta
builds" switch instead; on, it reads as Alpha.
- the release smoke test: the macOS build runs the game offscreen on the Mac mini and keeps its report,
`smoke-test.json`, only when the game drew a world. The gate reads the report again before publishing, and the
release carries it. A run whose macOS build went to GitHub (`MAC_RUNNER=macos-latest`) has no report, so it
publishes nothing;
- no open issue labelled `release-blocker`. `decide` checks first, so a blocked train builds nothing; the gate checks
again right before publishing, so an issue labelled while the builds ran still holds the release. Only the label
and the issue's state count, never its text: whoever opened the issue can edit the text after a maintainer
labels it. Close the issue (or remove the label) and the next run releases everything waiting, with notes
covering all of it.
`horizon-server update` installs stable builds (`--version <tag>` takes an alpha build by name). In Discord's #releases every
Alpha is posted silently and only Stable posts notify (above).
A failed gate fails the run; the next run (10 minutes later) decides again. Nothing older than the latest release is
ever published: tags only count up.
`.github/workflows/promote.yml` runs `tools/release_channels.py promote` once a day at 20:00 UTC (21:00 in Paris in
winter time, 22:00 in summer), with retries until 21:37 for a run GitHub starts late or skips. It promotes the newest
beta that
The launcher has no channel setting any more and looks for releases every 30 minutes. The game pre-releases of the
old Alpha channel (v0.2.1 on, published before this change) stay listed for anyone who wants one by hand, but no
launcher installs them as an update, and `release_train.py` ignores them: the first release of the single channel
lists everything since the last release that was not a pre-release (v0.2.0). Launchers from before this change keep
updating either way: with Alpha (or 0.17's "Beta builds") on they take every release, pre-release or not; with it
off, every release that is not a pre-release, which is now every new one.
- is newer than the stable release,
- had been out 6 hours at 20:00 UTC (published by 14:00 UTC that day; one published later waits for the next day),
so every run of the evening picks the same beta and stable moves once a day,
- passed its release smoke test: the macOS build attaches `smoke-test.json` (the offscreen game run's report) only
when the game drew a world on the Mac mini, so a release built while `MAC_RUNNER` sends macOS to GitHub waits
for a hand,
- and isn't covered by an issue labelled `release-blocker`. An open blocker holds every beta; closed as completed
it still holds the betas published before it closed (they have the bug); closed as not planned it holds
nothing. Only the label and the issue's state count, never its text: whoever opened the issue can edit the
text after a maintainer labels it, so it can't pick which betas go out.
Nothing older than the stable release is ever promoted, not even with `force`: GitHub's latest release is what
`horizon-server update` and `install.sh` download, so it only moves forward.
When the oldest beta newer than the stable release has waited 30 hours (it missed a daily promotion it was due for;
on a normal day a beta waits at most that long and is promoted), promote.yml opens an issue titled
"Promotion to stable has stalled" with the reason from the last check (a blocker left open, smoke tests not
running). It closes itself after the next promotion.
Promoting marks the beta stable and latest and rewrites its notes to list everything since the previous stable
release (`release_train.py notes --stable`), so players who skipped the betas see all of it. Older betas stay
betas. By hand, from a phone too (GitHub app: Actions, promote, Run workflow):
```sh
gh workflow run promote -f tag=v0.2.3 # that beta now: no wait for 20:00, still no blocker
gh workflow run promote -f tag=v0.2.3 -f force=true # even blocked or without a smoke test
gh workflow run promote -f dry_run=true # what would be promoted now
```
An alpha build's notes cover only what changed since the release before it (alpha or stable), and say it is an alpha build.
`release_train.py decide` counts pull requests from the newest release of either channel, so betas don't
release the same changes again.
`promote.yml` and `tools/release_channels.py` (the daily promotion at 20:00 UTC) are gone.
### Signed checksums
@@ -298,7 +272,8 @@ gh variable set RELEASES_PAUSED --body 1 # no automatic game or launcher rel
gh variable delete RELEASES_PAUSED # back on; the next hourly run catches up
```
While paused, runs still decide and say why in their summary; `force=true` still releases.
While paused, runs still decide and say why in their summary; `force=true` still releases, but never past an open
`release-blocker`.
### Rolling back
@@ -308,8 +283,8 @@ A bad release is withdrawn by turning it back into a draft:
gh release edit v0.2.3 --draft
```
A beta that is not promoted yet needs no rollback: label an issue about it `release-blocker`. For a stable
release: launchers list only published releases, so the previous version becomes the newest one again, launchers that
To stop the next releases while a problem is fixed, label an issue about it `release-blocker`. For the bad
release itself: launchers list only published releases, so the previous version becomes the newest one again, launchers that
didn't install the bad one won't, and players who did can pick the previous version in the launcher's version
list (`launcher/src/backend.rs` tests `withdrawn_release_is_never_offered`). The tag stays, so the next release
that day takes the next number, and its notes start from the last *published* release, so they list the
+10 -11
View File
@@ -38,7 +38,7 @@ and some are waiting for Ludovic:
| Private vulnerability reporting | **Live** (see the end of this page). |
| Signed checksums on releases | **Live** for new releases (#1011): the `release` environment holds the key, and its public half is in `release-keys.txt` (#1023). Releases from before then are unsigned. |
| Launchers and servers checking signatures | **Not yet**: #1014 waits as a draft until a signed stable game release and a signed launcher release exist. Until then they trust GitHub's checksum, as before. |
| Alpha, then stable | **Live** (#1012): every release is first an alpha build. The launcher's Updates setting picks Stable (the default) or Alpha (the pre-releases). |
| Smoke test and release-blocker before publishing | **Live**: since 2026-10-23 every release is the latest at once (one channel, Ludovic's decision), so it is published only after its smoke test passed and while no `release-blocker` is open. The 6-hour beta of the old Alpha, then stable flow (#1012) is gone. |
| Reports marked suspicious | **Partly**: the scan of Discord for agents does it. Kira's issues do it once its pull request merges. |
| Code owners' review of the pipeline | **Not yet**: `.github/CODEOWNERS` and its protection (#1015) wait for the App, below. Until then pipeline changes merge like any other once `verify` passes. |
| Agents on their own GitHub App | **Not yet**: prepared, waiting for Ludovic to create it. Agents still use his account. |
@@ -53,11 +53,10 @@ and some are waiting for Ludovic:
- **`verify` always runs.** Labels no longer start the checks, and the required `verify` job has no condition, so
the only `verify` a change can have is one that ran. Main requires it, from GitHub Actions only, for everyone,
admins included.
- **Alpha, then stable.** Every release is first an **alpha** build (a GitHub pre-release). It becomes **stable**, the version launchers and
servers install by themselves, in the daily promotion (20:00 UTC), only after it has been out 6 hours and passed its
smoke test (the game started and drew a world on the Mac mini), and only while no issue labelled `release-blocker`
is open. If a beta misses a promotion it was due for, an issue says so. Players who want the newest builds at once
pick **Alpha** under Updates in the launcher's Settings. Discord announces stable releases only.
- **Checked before it is published.** Every release is published as the latest, which launchers and servers
install by themselves, so the release run publishes it only after its smoke test passed (the game started and
drew a world on the Mac mini) and only while no issue labelled `release-blocker` is open; otherwise nothing is
published.
- **Signed updates.** Each release carries a list of its files' checksums, signed with Horizon's release key. The
launcher (for the game and for itself) and `horizon-server update` check that signature against the public key
built into them, then check the download against the list. Anything unsigned, signed with another key, signed
@@ -78,9 +77,8 @@ and some are waiting for Ludovic:
## What still needs Ludovic
- **Approving pipeline changes** (rare): a pull request touching a file in `.github/CODEOWNERS`.
- **Holding a release back or speeding one up**, from his phone if he likes. Label an issue `release-blocker` to
hold every beta until it is closed. Run the `promote` workflow (GitHub app: Actions,
promote, Run workflow) to make a beta stable at once.
- **Holding releases back or speeding one up**, from his phone if he likes. Label an issue `release-blocker` to
hold every release until it is closed; label a pull request `release-now` to release it as soon as it merges.
- **Creating the GitHub App**: one link, "Create GitHub App", then "Install". His login, his clicks.
- **Fork pull requests**: their checks run only after a maintainer approves them, and they run on the Mac mini only
after a maintainer has read them and added `run-parity`.
@@ -90,8 +88,9 @@ and some are waiting for Ludovic:
## What this doesn't solve
- **Code that merges is code that ships.** Signing proves a build came from Horizon's pipeline, not that the code
is good. Against a bad change, the safety nets are the checks and harnesses, review, the 6-hour beta and the
release-blocker label.
is good. Against a bad change, the safety nets are the checks and harnesses, review, the release smoke test and
the release-blocker label. Since the single channel there is no 6-hour beta: a change reaches every player
within minutes of merging.
- **Until agents switch to the App**, they still work with Ludovic's own account, so nothing technical tells his
actions from theirs. Code owners' reviews are switched on together with the App.
- **The Mac mini** builds the macOS release and also runs pull requests' parity checks: a pull request from this
+8 -8
View File
@@ -22,10 +22,10 @@ game and the launcher of the 0.2 line.
0.2. A `feature` label no longer bumps anything: it only lists the change under **New**.
- **Patch releases** are every release the train makes: the patch after the newest release tagged on the line, game
and launcher counted separately (the game releases more often, so `v0.2.14` can go with `[email protected]`).
- **Alpha builds** are not separate tags. As before, every game release is published as a GitHub pre-release (an alpha build, what
the launcher's Alpha channel installs) under its final tag and promoted in place to stable in the daily
promotion at 20:00 UTC, once it has been out 6 hours ([Channels](release-flow.md#channels)). `v0.2.4` is the same
build whether it is still a beta or already stable.
- **One channel** (since 2026-10-23): every game release is published under its final tag as the release and
GitHub's latest, once its smoke test passed and no `release-blocker` is open
([One channel](release-flow.md#one-channel)). The game pre-releases from before (v0.2.1 on, the old Alpha
channel) keep their tags and stay pre-releases.
- **Pre-release tags** (`v0.3.0-rc.1`, `v0.3.0-beta.2`) are semver's and are reserved for a hand-made preview of a
new line; the train never makes them. Every reader orders them before their release and after the release
before (`v0.2.9` < `v0.3.0-beta.1` < `v0.3.0-rc.1` < `v0.3.0`).
@@ -43,7 +43,7 @@ Every 0.2 tag is newer than every tag from before 0.2. Within each:
A tag is a 0.2 tag when it is `v` or `launcher@` followed by exactly three numbers (and an optional `-PRE`) with a
major below 1000; a date's first number is a year, so `v2026.10.21` never is. The rule is written once per
language, with the same tests: `crates/horizon-server/src/update.rs` (`newer`), `launcher/src/backend.rs`
(`release_order`), `tools/release_train.py` (`tag_key`, also used by `tools/release_channels.py`) and
(`release_order`), `tools/release_train.py` (`tag_key`) and
`website/src/versions.js`.
## The workflows
@@ -55,9 +55,6 @@ language, with the same tests: `crates/horizon-server/src/update.rs` (`newer`),
- **`launcher.yml`**: `decide --kind launcher` picks `[email protected]`; the build gets `HORIZON_LAUNCHER_VERSION`
(`0.2.N`) and `HORIZON_LAUNCHER_TAG` (`[email protected]`). The tag tells the launcher which line it is on: a release
built with only the version (every launcher before 0.2) is a `launcher-v` one.
- **`promote.yml`**: `tools/release_channels.py` promotes the newest due beta, never one older than the stable
release; with the shared order, `v0.2.0` follows the last dated stable release and no dated beta can follow
`v0.2.0`.
- **Notes**: a line's first release (`X.Y.0`) opens with a paragraph saying so; `v0.2.0`'s and `[email protected]`'s say
that releases are numbered from now on, and tell server owners to run `horizon-server update` (or, if their server
says it is up to date, `horizon-server update --version v0.2.0`).
@@ -98,6 +95,9 @@ the fleet's `release-kick` and the orchestrator's heartbeat prompts.
## The day Ludovic says go
Done on 2026-10-22; kept as the record of how 0.2 went out. `promote.yml` and `tools/release_channels.py`, which
it uses, are gone since the single channel (2026-10-23, [One channel](release-flow.md#one-channel)).
Run by the orchestrator, in this order. Until step 3, nothing is published.
1. Merge, in order: this page, the `horizon-server update` order, the launcher order, the release line (train,
-147
View File
@@ -1,147 +0,0 @@
import datetime as dt
import unittest
from tools import release_channels as channels
from tools import release_train as train
# Just after the daily promotion (PROMOTE_AT, 20:00 UTC): a beta is due when it had been out 6 hours by 20:00.
NOW = dt.datetime(2026, 10, 19, 20, 30, tzinfo=dt.timezone.utc)
def release(tag, hours_ago, prerelease=True, smoke=True, draft=False):
published = (NOW - dt.timedelta(hours=hours_ago)).isoformat().replace("+00:00", "Z")
assets = [{"name": "horizon-linux-x86_64.tar.gz"}] + ([{"name": channels.SMOKE_ASSET}] if smoke else [])
return {"tag_name": tag, "published_at": published, "prerelease": prerelease, "draft": draft, "assets": assets}
def blocker(number, title, body="", state="open", reason=None, closed_hours_ago=None):
closed = None if closed_hours_ago is None else (NOW - dt.timedelta(hours=closed_hours_ago)).isoformat()
return {"number": number, "title": title, "body": body, "state": state, "state_reason": reason, "closed_at": closed}
STABLE = release("v2026.10.18.04", 30, prerelease=False)
class ReleaseChannelTests(unittest.TestCase):
def test_the_newest_beta_out_six_hours_with_a_smoke_test_is_promoted(self):
releases = [STABLE, release("v2026.10.19", 9), release("v2026.10.19.01", 7), release("v2026.10.19.02", 2)]
chosen, why = channels.choose(releases, [], NOW)
self.assertEqual(chosen["tag_name"], "v2026.10.19.01")
self.assertIn("6 h", why)
def test_nothing_is_due_before_six_hours(self):
chosen, why = channels.choose([STABLE, release("v2026.10.19", 6)], [], NOW)
self.assertIsNone(chosen, "out 5 h 30 min at 20:00")
self.assertIn("the one at 2026-10-20 20:00 UTC takes it", why)
late = channels.choose([STABLE, release("v2026.10.19", 0.5)], [], NOW)[1]
self.assertIn("the one at 2026-10-20 20:00 UTC takes it", late, "out after 20:00: 6 h by the next one")
def test_stable_moves_once_a_day_whenever_the_run_starts(self):
hour = dt.timedelta(hours=1)
releases = [STABLE, release("v2026.10.19", 9), release("v2026.10.19.01", 6)]
# Before 20:00 nothing new is due, even what has been out 6 hours by now.
before = channels.choose(releases, [], NOW - hour)
self.assertIsNone(before[0], before[1])
# The 20:00 run and a late one or a retry pick the same beta; once it is stable, the evening's later runs
# promote nothing, though v2026.10.19.01 has been out 6 hours by then.
for late in (0, 1, 3):
self.assertEqual(channels.choose(releases, [], NOW + late * hour)[0]["tag_name"], "v2026.10.19")
promoted = [release("v2026.10.19", 9, prerelease=False), releases[2]]
self.assertIsNone(channels.choose(promoted, [], NOW + 3 * hour)[0])
# A run GitHub skipped is caught up by the next one, at any hour before the next day's promotion.
self.assertEqual(channels.choose(releases, [], NOW + 20 * hour)[0]["tag_name"], "v2026.10.19")
# The next day's promotion takes the one that came out too late for today's.
self.assertEqual(channels.choose(promoted, [], NOW + 24 * hour)[0]["tag_name"], "v2026.10.19.01")
self.assertEqual(channels.promotion_time(NOW), dt.datetime(2026, 10, 19, 20, 0, tzinfo=dt.timezone.utc))
self.assertEqual(channels.promotion_time(NOW - hour), dt.datetime(2026, 10, 18, 20, 0, tzinfo=dt.timezone.utc))
def test_a_beta_without_its_smoke_test_waits_for_a_hand(self):
releases = [STABLE, release("v2026.10.19", 8, smoke=False)]
self.assertIsNone(channels.choose(releases, [], NOW)[0])
self.assertIsNone(channels.choose(releases, [], NOW, tag="v2026.10.19")[0])
self.assertEqual(channels.choose(releases, [], NOW, tag="v2026.10.19", force=True)[0]["tag_name"], "v2026.10.19")
def test_by_hand_skips_the_wait_but_not_a_blocker(self):
releases = [STABLE, release("v2026.10.19.02", 1)]
self.assertEqual(channels.choose(releases, [], NOW, tag="v2026.10.19.02")[0]["tag_name"], "v2026.10.19.02")
blocked = [blocker(5, "Crash on start in 2026.10.19.2")]
chosen, why = channels.choose(releases, blocked, NOW, tag="v2026.10.19.02")
self.assertIsNone(chosen)
self.assertIn("#5", why)
self.assertIsNotNone(channels.choose(releases, blocked, NOW, tag="v2026.10.19.02", force=True)[0])
def test_stable_releases_and_drafts_are_never_promoted(self):
self.assertIsNone(channels.choose([STABLE], [], NOW, tag=STABLE["tag_name"])[0])
withdrawn = release("v2026.10.19", 9, draft=True)
self.assertIsNone(channels.choose([STABLE, withdrawn], [], NOW)[0])
older = release("v2026.10.18.03", 40)
self.assertIsNone(channels.choose([STABLE, older], [], NOW)[0])
def test_an_open_blocker_holds_every_beta_whatever_its_text_says(self):
releases = [STABLE, release("v2026.10.19", 12), release("v2026.10.19.01", 10), release("v2026.10.19.02", 8)]
# The issue's author can edit its text after it is labelled: naming a later version must not free a beta.
for text in ("Something is badly wrong", "Only in v2099.01.01", "Since v2026.10.19.01 the chests…"):
chosen, why = channels.choose(releases, [blocker(7, text, body=text)], NOW)
self.assertIsNone(chosen, text)
self.assertIn("release-blocker #7", why)
def test_a_fixed_blocker_still_holds_the_betas_published_before_the_fix(self):
fixed = blocker(9, "Crash", state="closed", reason="completed", closed_hours_ago=7.5)
releases = [STABLE, release("v2026.10.19", 12), release("v2026.10.19.01", 7)]
self.assertEqual(channels.choose(releases, [fixed], NOW)[0]["tag_name"], "v2026.10.19.01")
self.assertIsNone(channels.choose(releases[:2], [fixed], NOW)[0])
not_planned = blocker(10, "Crash", state="closed", reason="not_planned", closed_hours_ago=1)
self.assertEqual(channels.choose(releases[:2], [not_planned], NOW)[0]["tag_name"], "v2026.10.19")
def test_force_never_moves_latest_backwards(self):
# horizon-server update and install.sh download GitHub's latest: an older beta must never become it.
older = release("v2026.10.18.03", 40, smoke=False)
chosen, why = channels.choose([STABLE, older], [blocker(3, "x")], NOW, tag="v2026.10.18.03", force=True)
self.assertIsNone(chosen)
self.assertIn("not newer than the stable v2026.10.18.04", why)
same = release("v2026.10.18.04", 30, prerelease=False)
self.assertIsNone(channels.choose([same], [], NOW, tag="v2026.10.18.04", force=True)[0])
def test_0_2_follows_the_dated_releases(self):
bridge = release("v2026.10.21", 30, prerelease=False)
first = release("v0.2.0", 7)
chosen, why = channels.choose([bridge, first], [], NOW)
self.assertEqual(chosen["tag_name"], "v0.2.0", why)
stable = release("v0.2.0", 30, prerelease=False)
older = release("v2026.10.22", 8)
self.assertIsNone(channels.choose([stable, older], [], NOW)[0], "a dated beta never follows 0.2")
chosen, why = channels.choose([stable, older], [], NOW, tag="v2026.10.22", force=True)
self.assertIsNone(chosen)
self.assertIn("not newer than the stable v0.2.0", why)
patch = release("v0.2.1", 7)
self.assertEqual(channels.choose([stable, older, patch], [], NOW)[0]["tag_name"], "v0.2.1")
self.assertEqual(channels.stable([stable, bridge])["tag_name"], "v0.2.0")
launcher = release("[email protected]", 7)
self.assertIsNone(channels.choose([bridge, launcher], [], NOW)[0], "launcher releases are never game betas")
def test_a_stall_is_reported_once_the_oldest_waiting_beta_missed_a_daily_promotion(self):
# Out at 14:31 yesterday: just too late for yesterday's 20:00, so 29 h 59 min of waiting is a normal day.
self.assertIsNone(channels.stalled([STABLE, release("v2026.10.19", 29.99), release("v2026.10.19.01", 2)], NOW))
releases = [STABLE, release("v2026.10.19", 31), release("v2026.10.19.01", 2)]
oldest, age = channels.stalled(releases, NOW)
self.assertEqual(oldest["tag_name"], "v2026.10.19")
title, body = channels.stall_issue(releases, (oldest, age), "no beta is due (release-blocker #4)")
self.assertEqual(title, "Promotion to stable has stalled: v2026.10.19 has waited 31 h")
self.assertIn("v2026.10.18.04", body)
self.assertIn("release-blocker #4", body)
# Betas older than the stable release are contained in it: they aren't waiting.
self.assertIsNone(channels.stalled([STABLE, release("v2026.10.18.03", 40)], NOW))
self.assertIsNone(channels.stalled([STABLE], NOW))
def test_beta_notes_say_so_and_stable_notes_do_not(self):
pr = {"number": 1, "title": "Fix a thing", "labels": [], "paths": ["crates/x.rs"], "issues": [], "author": "ludovic111"}
_, beta = train.notes("game", "v2026.10.19", [pr], [], beta=True)
_, stable = train.notes("game", "v2026.10.19", [pr], [], beta=False)
self.assertIn("This is an alpha build: launchers on the Alpha channel", beta)
self.assertNotIn("alpha", stable.lower())
_, launcher = train.notes("launcher", "launcher-v0.9.0", [pr], [], beta=True)
self.assertNotIn("alpha", launcher.lower())
if __name__ == "__main__":
unittest.main()
+27 -1
View File
@@ -104,7 +104,7 @@ class ReleaseTrainTests(unittest.TestCase):
self.assertFalse(train.decision("game", [], NOW)[0])
def test_the_first_release_of_0_2_says_what_changed(self):
title, body = train.notes("game", "v0.2.0", [pr(1)], [], beta=True, previous="v2026.10.21")
title, body = train.notes("game", "v0.2.0", [pr(1)], [], previous="v2026.10.21")
self.assertTrue(title.startswith("Horizon 0.2.0: "), title)
self.assertTrue(body.startswith("Horizon is numbered from now on: this is Horizon 0.2.0, the release after 2026.10.21."))
self.assertIn("`horizon-server update --version v0.2.0`", body)
@@ -132,6 +132,32 @@ class ReleaseTrainTests(unittest.TestCase):
self.assertFalse(decide([pr(1)], paused=True))
self.assertTrue(decide([], paused=True, force=True))
def test_an_open_release_blocker_holds_every_release_even_forced(self):
release, why = train.decision("game", [pr(1, labels=["release-now"])], NOW, blockers=[7, 9])
self.assertFalse(release)
self.assertEqual(why, "held by release-blocker #7, release-blocker #9")
self.assertFalse(train.decision("game", [], NOW, force=True, blockers=[7])[0])
self.assertFalse(train.decision("game", [], NOW, new_line="0.3", blockers=[7])[0])
self.assertTrue(train.decision("game", [pr(1)], NOW, blockers=[])[0])
def test_a_release_is_published_only_after_its_smoke_test_and_with_no_blocker(self):
drew = {"error": None, "frames": 12710, "chunks": 81, "meshed_sections": 550, "player": [160.5, 71.0, 156.5]}
self.assertIsNone(train.gate(drew, []))
self.assertEqual(train.gate(drew, [4]), "release-blocker #4 is open")
self.assertEqual(train.gate(None, []), "no smoke-test.json: the release smoke test did not run")
self.assertEqual(train.gate(None, [4]),
"no smoke-test.json: the release smoke test did not run; release-blocker #4 is open")
self.assertEqual(train.smoke_problem({**drew, "error": "device lost"}), "the smoke run failed: device lost")
for broken in ({**drew, "frames": 1499}, {**drew, "chunks": 0}, {**drew, "meshed_sections": 0},
{**drew, "player": None}, {}):
self.assertEqual(train.smoke_problem(broken), "the smoke run did not draw a world", broken)
def test_notes_never_call_a_release_alpha_or_beta(self):
_, body = train.notes("game", "v0.2.12", [pr(1)], [])
self.assertNotIn("alpha", body.lower())
self.assertNotIn("beta", body.lower())
self.assertNotIn("Stable", body)
def test_notes(self):
prs = [pr(5, "Nether portals: vanilla timing and linking (fixes #94)", issues=[94]),
pr(6, "Controllers: gilrs input layer and gameplay", labels=["feature"], author="cosmic"),
-220
View File
@@ -1,220 +0,0 @@
#!/usr/bin/env python3
"""Promotes Horizon's beta game releases to stable once a day (.github/workflows/promote.yml, docs/release-flow.md).
python3 tools/release_channels.py promote # the newest beta that is due, if any
python3 tools/release_channels.py promote --tag v2026.10.19.03 # that beta now (by hand)
python3 tools/release_channels.py promote --tag v2026.10.19.03 --force # even if blocked or untested
python3 tools/release_channels.py promote --dry-run # say what would happen, change nothing
Every game release goes out as a beta (a GitHub pre-release): launchers on the Alpha channel install it at once.
Stable moves once a day, at PROMOTE_AT (20:00 UTC). A beta is due for stable when:
- it is newer than the stable release (GitHub's "latest", which launchers and servers install by default),
- it had been out PROMOTE_AFTER (6 hours) at the day's promotion time, so every run until the next one picks the
same beta and a run GitHub starts late (or a second try) never promotes twice; by hand (--tag) skips the wait,
- its release smoke test passed: release.yml attaches smoke-test.json only after the Mac mini ran the game,
- and no issue labelled release-blocker covers it. An open blocker holds every beta; one closed as completed keeps
holding the betas published before it closed (they still have the bug); one closed as not planned holds nothing.
Only people who can triage the repository can add the label, and the issue's text is never read: whoever wrote
the issue could edit it afterwards, so it can't decide which betas go out.
The newest due beta is promoted: marked stable and latest, with notes covering everything since the previous
stable release (tools/release_train.py notes --stable). Older betas stay betas: the stable one contains them.
Nothing older than the current stable release is ever promoted, even with --force: GitHub's "latest" is what
horizon-server update and install.sh download, and it only moves forward.
When the oldest beta newer than the stable release has waited STALL_AFTER (30 hours: past one daily promotion it
was due for, so a normal day never trips it), promotion has stalled
(blocked, smoke tests not running, the schedule off): an issue says so, and is closed once a beta is promoted.
"""
import argparse
import datetime as dt
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from tools.release_train import is_game_tag, tag_key # noqa: E402 (vYYYY.MM.DD[.N] and v0.2.0: docs/versioning.md)
REPO = os.environ.get("GITHUB_REPOSITORY", "ludovic111/horizon")
PROMOTE_AFTER = dt.timedelta(hours=6)
# The daily promotion (21:00 in Paris in winter time, 22:00 in summer); promote.yml's schedule runs just after it.
PROMOTE_AT = dt.time(20, 0)
SMOKE_ASSET = "smoke-test.json"
BLOCKER = "release-blocker"
STALL_AFTER = dt.timedelta(hours=30)
STALLED = "Promotion to stable has stalled"
def parse_time(text):
return dt.datetime.fromisoformat(text.replace("Z", "+00:00")) if text else None
def covers(blocker, release):
"""Whether a release-blocker issue holds this beta back. Only its state and dates count, never its text."""
if blocker.get("state") != "closed":
return True
closed = parse_time(blocker.get("closed_at"))
return blocker.get("state_reason") == "completed" and bool(closed) and parse_time(release["published_at"]) < closed
def promotion_time(now):
"""The last daily promotion time (PROMOTE_AT, UTC) at or before now."""
now = now.astimezone(dt.timezone.utc)
at = dt.datetime.combine(now.date(), PROMOTE_AT, dt.timezone.utc)
return at if at <= now else at - dt.timedelta(days=1)
def stable(releases):
"""The newest stable (non pre-release) game release."""
stables = [r for r in releases if not r["prerelease"]]
return max(stables, key=lambda r: tag_key(r["tag_name"]), default=None)
def reasons_against(release, current, blockers, now, by_hand=False):
"""Why this beta can't become stable now: an empty list when it can."""
reasons = []
if not release["prerelease"]:
reasons.append("it is already stable")
if current and tag_key(release["tag_name"]) <= tag_key(current["tag_name"]):
reasons.append(f"it is not newer than the stable {current['tag_name']}")
ready = parse_time(release["published_at"]) + PROMOTE_AFTER
if not by_hand and ready > promotion_time(now):
due = promotion_time(ready)
due += dt.timedelta(days=1 if due < ready else 0)
reasons.append(f"it had not been out {PROMOTE_AFTER.seconds // 3600} h at a daily promotion yet: "
f"the one at {due:%Y-%m-%d %H:%M} UTC takes it")
if not any(asset["name"] == SMOKE_ASSET for asset in release.get("assets", [])):
reasons.append(f"it has no {SMOKE_ASSET}: the release smoke test did not run")
for blocker in blockers:
if covers(blocker, release):
reasons.append(f"release-blocker #{blocker['number']}")
return reasons
def choose(releases, blockers, now, tag=None, force=False):
"""(the beta to promote or None, a line saying why) for the game releases `releases`."""
games = [r for r in releases if not r["draft"] and is_game_tag(r["tag_name"])]
current = stable(games)
if tag:
release = next((r for r in games if r["tag_name"] == tag), None)
if not release:
return None, f"no published game release is tagged {tag}"
reasons = reasons_against(release, current, blockers, now, by_hand=True)
if force:
# Even forced, "latest" only moves forward: horizon-server update and install.sh follow it.
reasons = [r for r in reasons if r == "it is already stable" or r.startswith("it is not newer")]
if reasons:
return None, f"{tag} stays a beta: " + "; ".join(reasons)
return release, f"{tag} promoted by hand" + (" with force" if force else "")
betas = sorted((r for r in games if r["prerelease"]), key=lambda r: tag_key(r["tag_name"]), reverse=True)
held = []
for release in betas:
reasons = reasons_against(release, current, blockers, now)
if not reasons:
return release, f"{release['tag_name']} had been out {PROMOTE_AFTER.seconds // 3600} h at the daily promotion, " \
"passed its smoke test and no release-blocker covers it"
held.append(f"{release['tag_name']}: " + "; ".join(reasons))
return None, "no beta is due" + (" (" + " / ".join(held[:3]) + ")" if held else "")
def stalled(releases, now):
"""(the oldest beta newer than the stable release, how long it has waited) once that is STALL_AFTER, else None."""
games = [r for r in releases if not r["draft"] and is_game_tag(r["tag_name"])]
current = stable(games)
waiting = [r for r in games if r["prerelease"] and (not current or tag_key(r["tag_name"]) > tag_key(current["tag_name"]))]
if not waiting:
return None
oldest = min(waiting, key=lambda r: parse_time(r["published_at"]))
age = now - parse_time(oldest["published_at"])
return (oldest, age) if age >= STALL_AFTER else None
def stall_issue(releases, stall, reason):
"""The title and body of the issue saying promotion has stalled."""
oldest, age = stall
current = stable([r for r in releases if not r["draft"] and is_game_tag(r["tag_name"])])
hours = int(age.total_seconds() // 3600)
body = (f"Opened by .github/workflows/promote.yml, not by a person.\n\n"
f"No beta has become stable for {hours} hours: {oldest['tag_name']} has waited since "
f"{oldest['published_at']}, and launchers, servers and install.sh still get "
f"{current['tag_name'] if current else 'no stable release'}.\n\n"
f"The last check said: {reason}\n\n"
"Usual causes: an open `release-blocker` issue (it holds every beta until it is closed), the Mac mini's "
"release smoke test not running (no smoke-test.json on the betas), or the promote workflow disabled. "
"To promote a beta by hand: `gh workflow run promote -f tag=<tag>` (add `-f force=true` to skip the "
"smoke test and blockers). This issue closes itself once a beta is promoted.")
return f"{STALLED}: {oldest['tag_name']} has waited {hours} h", body
def gh(*arguments, capture=True):
result = subprocess.run(["gh", *arguments], cwd=ROOT, capture_output=capture, text=True)
if result.returncode:
raise SystemExit(f"gh {' '.join(arguments)}: {(result.stderr or '').strip()}")
return result.stdout
def gh_pages(path):
return [item for page in json.loads(gh("api", path, "--paginate", "--slurp") or "[]") for item in page]
def promote(release):
"""Marks the beta stable and latest, with notes covering everything since the previous stable release."""
tag = release["tag_name"]
with tempfile.TemporaryDirectory() as out:
subprocess.run([sys.executable, str(ROOT / "tools" / "release_train.py"), "notes", "--kind", "game", "--tag", tag,
"--head", tag, "--stable", "--out", out], cwd=ROOT, check=True, stdout=subprocess.DEVNULL)
title = (Path(out) / "title.txt").read_text().strip()
gh("release", "edit", tag, "--repo", REPO, "--prerelease=false", "--latest", "--title", title,
"--notes-file", str(Path(out) / "body.md"))
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("command", choices=["promote"])
parser.add_argument("--tag", help="promote this beta now (by hand: no wait for the daily promotion)")
parser.add_argument("--force", action="store_true", help="with --tag: even without a smoke test or with a blocker")
parser.add_argument("--dry-run", action="store_true", help="say what would be promoted, change nothing")
parser.add_argument("--now", help="the time to decide at (ISO 8601; default now)")
args = parser.parse_args()
if args.force and not args.tag:
parser.error("--force needs --tag: only a named beta is forced")
now = dt.datetime.fromisoformat(args.now) if args.now else dt.datetime.now(dt.timezone.utc)
now = now if now.tzinfo else now.replace(tzinfo=dt.timezone.utc)
releases = gh_pages(f"repos/{REPO}/releases?per_page=100")
blockers = [issue for issue in gh_pages(f"repos/{REPO}/issues?labels={BLOCKER}&state=all&per_page=100")
if "pull_request" not in issue]
release, reason = choose(releases, blockers, now, args.tag, args.force)
if release and not args.dry_run:
promote(release)
line = ("Would promote: " if release and args.dry_run else "") + reason
print(line)
if os.environ.get("GITHUB_STEP_SUMMARY"):
with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
summary.write(f"### {line}\n")
report_stall(releases, release, reason, now, args.dry_run)
return 1 if args.tag and not release else 0
def report_stall(releases, promoted, reason, now, dry_run):
"""Opens the stalled-promotion issue when one is due and none is open; closes it after a promotion."""
open_issues = [issue for issue in gh_pages(f"repos/{REPO}/issues?state=open&per_page=100")
if "pull_request" not in issue and issue["title"].startswith(STALLED)]
stall = None if promoted else stalled(releases, now)
if stall and not open_issues:
title, body = stall_issue(releases, stall, reason)
print(("Would open: " if dry_run else "Opening: ") + title)
if not dry_run:
gh("issue", "create", "--repo", REPO, "--title", title, "--body", body)
if promoted and not dry_run:
for issue in open_issues:
gh("issue", "close", str(issue["number"]), "--repo", REPO, "--comment",
f"{promoted['tag_name']} is stable now (promote.yml).")
if __name__ == "__main__":
sys.exit(main())
+71 -28
View File
@@ -4,13 +4,14 @@
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
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) 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.
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 [email protected], each the patch after the last tag of its kind
@@ -19,9 +20,11 @@ file, the tags from before 0.2: vYYYY.MM.DD, then .01, .02 the same UTC day, and
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.
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.
"""
@@ -37,7 +40,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
REPO = os.environ.get("GITHUB_REPOSITORY", "ludovic111/horizon")
# Alpha builds ship constantly: a merge starts a release within minutes, once its wave of merges has settled.
# Releases ship constantly: a merge starts a release within minutes, once its wave of merges has settled.
DEBOUNCE = dt.timedelta(minutes=5)
MAX_WAIT = dt.timedelta(minutes=20)
# Kept as typed in release titles, where the clauses after the first are lowercased.
@@ -72,12 +75,13 @@ 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 an alpha build: launchers on the Alpha channel (Settings, Updates) install it now; Stable gets it "
"in the next daily update if it holds up.",
"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."
# 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):
@@ -225,11 +229,37 @@ def pull_request(number):
}
def published_releases(betas=True):
"""Published (not draft) releases with their tags and dates; without betas (pre-releases) when betas is False."""
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 (betas or not release["prerelease"])]
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):
@@ -238,13 +268,16 @@ def last_release(releases, kind):
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):
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."""
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:
@@ -323,9 +356,8 @@ def line_intro(kind, version, previous):
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."""
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"]]
@@ -346,17 +378,15 @@ def notes(kind, version, prs, issues, beta=False, previous=None):
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
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()
@@ -387,18 +417,29 @@ def write_outputs(values):
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("command", choices=["decide", "notes"])
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("--stable", action="store_true",
help="notes: for promoting a beta to stable, so since the last stable release, without the beta line")
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"}
@@ -406,11 +447,11 @@ def main():
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)
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(), beta=not args.stable, previous=previous)
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")
@@ -422,7 +463,9 @@ def main():
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)
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 = {
+4 -3
View File
@@ -55,7 +55,8 @@ try {
if (batch.length < 100) break;
}
if (!res.ok) console.warn(`releases: ${res.status} from GitHub; the pages will load them themselves`);
// A day of betas can push the stable release out of the newest 100: it is GitHub's "latest" (betas never are).
// Pre-releases of the old Alpha channel could push the newest release out of the newest 100: it is GitHub's
// "latest" (pre-releases never are).
if (res.ok && !releases.some((r) => isGame(r.tag_name) && !r.prerelease)) {
const latest = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { headers: githubHeaders, signal: AbortSignal.timeout(15000) });
if (latest.ok) releases.push(await latest.json());
@@ -121,8 +122,8 @@ const parityHeadline = matching === body.length ? "Every subsystem matches vanil
// --- The releases ----------------------------------------------------------------------
// Newest first by tag: every 0.2 tag is newer than the dated ones (src/versions.js). Alpha builds are GitHub
// pre-releases, Stable the others (docs/release-flow.md, "Channels").
// Newest first by tag: every 0.2 tag is newer than the dated ones (src/versions.js). One channel since 2026-10-23
// (docs/release-flow.md, "One channel"); the old Alpha channel's pre-releases stay listed.
const byTag = (a, b) => newestFirst(a.tag_name, b.tag_name);
const ours = releases.filter((r) => isGame(r.tag_name) || isLauncher(r.tag_name)).sort(byTag);
const game = ours.filter((r) => isGame(r.tag_name));
+1 -1
View File
@@ -60,7 +60,7 @@ Only if you say yes, and each kind has its own switch. Everything is off by defa
## How often does it update?
Often. Every change players can see is released automatically after it is merged, so new versions can come out several times a day. On the **Alpha** channel the launcher installs each one as soon as it is built; on **Stable**, the default, it installs a tested build about once a day. See [Install](/docs/install#updating).
Often. Every change players can see is released automatically after it is merged, so new versions can come out several times a day. Each one is tested before it goes out (the game has to start and draw a world), and the launcher installs it as soon as it is out. There is one version for everyone. See [Install](/docs/install#updating).
## What do the version numbers mean?
+1 -1
View File
@@ -7,7 +7,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust. You install it with a
- **A new game program, not a mod.** Horizon is written from scratch in Rust. It reads the same worlds, plays by the same rules and generates the same terrain as the Java game.
- **Checked against vanilla.** {{HARNESSES}} automated tests ("harnesses") feed the same inputs to Horizon and to the real game, then compare the results. Today every subsystem matches in every harness that compares it. That is {{PERCENT}} of what the harnesses compare, which is not proof that nothing else differs. The [status table](/#parity) lists what is covered.
- **Light and fast.** It starts quickly, uses little memory and draws efficiently. See [Performance](/docs/performance) for what is measured and what is not.
- **A few things vanilla doesn't have.** Its own settings, added to vanilla's menus the way mods add theirs, graphics presets, distant terrain, controller support, optional extras such as zoom, a minimap and dynamic lights (dynamic lights are in Alpha builds for now), an invite code to play your world with friends, and a server you can run without Java.
- **A few things vanilla doesn't have.** Its own settings, added to vanilla's menus the way mods add theirs, graphics presets, distant terrain, controller support, optional extras such as zoom, a minimap and dynamic lights, an invite code to play your world with friends, and a server you can run without Java.
## What Horizon is not
+4 -9
View File
@@ -34,19 +34,14 @@ The game opens on vanilla's title screen. Your worlds, options and logs live in
## Updating
- **Horizon.** The launcher looks for a new release when it starts and every four hours while it is open. With **Install new versions automatically** on (the default), it installs one and moves you to it if you were on the newest. New releases come out many times a day, and old versions stay installed until you remove them on the **Versions** page.
- **Horizon.** The launcher looks for a new release when it starts and every 30 minutes while it is open. With **Install new versions automatically** on (the default), it installs one and moves you to it if you were on the newest. New releases come out many times a day, and old versions stay installed until you remove them on the **Versions** page.
- **The launcher.** It updates itself from its own releases, then shows **Restart** in its footer. A launcher from before 0.2 (0.16 or older) updates twice in a row: first to 0.17, then to 0.2. Then it installs Horizon 0.2 like any update.
## Stable and Alpha
## One version for everyone
> **Next launcher release.** The Updates switch below is merged but not in a launcher release yet. Launcher 0.17 has a **Beta builds** switch in Settings instead, which installs every new build at once, as Alpha does.
There is one channel: every release is the version everyone gets. Before a release goes out, the game has to start and draw a world on our test machine, and nothing goes out while a serious problem is open. New releases come out many times a day, and you can install any older version by hand on the **Versions** page.
In **Settings**, **Updates** picks which builds install by themselves:
- **Stable** (the default): a tested build about once a day.
- **Alpha**: every change as soon as it is built, several times a day. It can break. A launcher left open looks for new builds every 30 minutes, instead of every four hours.
Every new release is an Alpha build first. Once a day (20:00 UTC, 21:00 in Paris in winter), the newest one that has been out for six hours, passed its smoke test and has no known blocker becomes Stable. Alpha builds come out many times a day, and Horizon as a whole is still in beta; Stable is the tested ones. The **Versions** page shows each version's channel, and you can install any of them by hand.
Launchers from before this change still show an **Updates** choice in Settings. Either option now gets every new release, and the choice goes away once the launcher has updated itself.
## The Versions page
+1 -1
View File
@@ -20,7 +20,7 @@ There is no Horizon server in the middle. The code holds the host's addresses an
Guests join with the name their game uses. There is no account check, so only share the code with people you trust.
In Alpha builds, guests can also use the host's workstations: the crafter, loom, stonecutter, grindstone, anvil, smithing table, enchanting table, beacon, lectern and cartography table. Stable gets this once it has passed.
Guests can also use the host's workstations: the crafter, loom, stonecutter, grindstone, anvil, smithing table, enchanting table, beacon, lectern and cartography table.
## Open to LAN
+2 -2
View File
@@ -46,7 +46,7 @@ Shader packs are **experimental**. Put a pack's folder in the `shaderpacks` fold
Horizon can run the full-screen effects of Java shader packs (OptiFine and Iris packs: colour grading, bloom, sky and fog effects). It does not run their geometry and shadow passes yet, so packs do not look as they do in Iris. Horizon also has its own shader pack format, in WGSL, with a `pack.toml` manifest, options and hot reload. It has full-screen passes too; stages that draw the world's terrain and shadows are planned. The details are in the [shader pack notes](https://github.com/ludovic111/horizon/blob/main/docs/shader-packs.md).
In Alpha builds, the **Shader Packs** page shows the full path of the `shaderpacks` folder with an **Open Folder** button, then one switch per pack. Turning a pack on picks it, and turning it off gives the vanilla picture. Packs tested so far: BSL, Complementary Reimagined, Steadfast and Sildur's Vibrant Extreme draw their full-screen passes; Bliss, Bloop, Noble, Photon and Rethinking Voxels do not load yet. To find packs, the launcher's **Mods** page installs shader packs from Modrinth (see [Mods and data packs](/docs/mods#finding-resource-packs-shader-packs-and-modpacks)).
The **Shader Packs** page shows the full path of the `shaderpacks` folder with an **Open Folder** button, then one switch per pack. Turning a pack on picks it, and turning it off gives the vanilla picture. Packs tested so far: BSL, Complementary Reimagined, Steadfast and Sildur's Vibrant Extreme draw their full-screen passes; Bliss, Bloop, Noble, Photon and Rethinking Voxels do not load yet. To find packs, the launcher's **Mods** page installs shader packs from Modrinth (see [Mods and data packs](/docs/mods#finding-resource-packs-shader-packs-and-modpacks)).
## Resource packs
@@ -67,7 +67,7 @@ Horizon plays with PlayStation (DualShock 4 and DualSense), Xbox (360, One and S
## Extras
**Options, Horizon Settings, Extras** holds small, optional conveniences. Every one is off by default, so a fresh install behaves like vanilla: fullbright, zoom, a minimap, an FPS counter, a keystroke display, auto-reconnect, Mouse Tweaks-style inventory controls and inventory sorting. In Alpha builds there is one more, **dynamic lights**: a torch, lantern or other light source that you hold, that lies dropped as an item, or a burning mob, lights the blocks around it. It is client-side only, so other players do not see it.
**Options, Horizon Settings, Extras** holds small, optional conveniences. Every one is off by default, so a fresh install behaves like vanilla: fullbright, zoom, a minimap, an FPS counter, a keystroke display, auto-reconnect, Mouse Tweaks-style inventory controls and inventory sorting. One more is **dynamic lights**: a torch, lantern or other light source that you hold, that lies dropped as an item, or a burning mob, lights the blocks around it. It is client-side only, so other players do not see it.
## Where your settings are stored
+1 -1
View File
@@ -202,7 +202,7 @@
<div class="section-head">
<p class="kicker reveal"><span>05</span>Releases</p>
<h2 id="recent-title" class="title reveal">Recently added</h2>
<p class="lede reveal">Alpha builds come out many times a day, as changes land; about once a day the tested ones become Stable, the launcher's default. The newest, and what they added:</p>
<p class="lede reveal">New versions come out many times a day, as changes land, and the launcher installs each one once it has passed its test. The newest, and what they added:</p>
</div>
<div class="recent reveal" id="recent-releases" aria-live="polite">
{{RECENT}}
+4 -4
View File
@@ -17,9 +17,9 @@ const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const isLauncher = (r) => isLauncherTag(r.tag_name) && !r.prerelease;
// The newest 100 releases (GitHub's most per page). Game betas come out several times a day, so the stable game
// release and the newest launcher release can both be older than all of them. The stable game release is then
// GitHub's "latest" (betas and launcher releases are never marked latest); the launcher release is looked up by its
// The newest 100 releases (GitHub's most per page). The newest game release that is not a pre-release (the old
// Alpha channel's, docs/release-flow.md) and the newest launcher release can be older than all of them. The game
// release is then GitHub's "latest" (pre-releases and launcher releases never are); the launcher release is looked up by its
// tag (the newest launcher@ tag with a published release, or launcher-v before 0.2: docs/versioning.md).
async function loadReleases() {
const list = (await get(`https://api.github.com/repos/${REPO}/releases?per_page=100`)).filter((r) => !r.draft).map(slim);
@@ -83,7 +83,7 @@ async function setUpDownload() {
// --- Recently added and the links from main ---------------------------------
// "Recently added": the newest game releases, Alpha and Stable.
// "Recently added": the newest game releases.
async function setUpRecent() {
const html = recentHtml((await releases).filter((r) => isGame(r.tag_name)));
if (html) document.getElementById("recent-releases").innerHTML = html;
+3 -4
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Releases · Horizon</title>
<meta name="description" content="Every Horizon release, newest first: what each one adds and fixes, its full notes and its downloads. Alpha builds and Stable releases.">
<meta name="description" content="Every Horizon release, newest first: what each one adds and fixes, its full notes and its downloads.">
<meta name="theme-color" content="#ece7f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f0d17" media="(prefers-color-scheme: dark)">
<meta name="color-scheme" content="light dark">
@@ -30,11 +30,10 @@
<div class="container">
<p class="kicker"><span>Releases</span>Every version, newest first</p>
<h1 class="display page-title">What's new in Horizon</h1>
<p class="lede">Each release lists what it adds and fixes, then its full notes and its downloads. <strong>Alpha</strong> builds come out many times a day as changes land and can break; about once a day the tested ones become <strong>Stable</strong>, what the launcher installs unless you pick Alpha in its Settings. The launcher has its own releases.</p>
<p class="lede">Each release lists what it adds and fixes, then its full notes and its downloads. New versions come out many times a day as changes land, and the launcher installs each one once it has passed its test. The launcher has its own releases.</p>
<div class="release-filter" role="group" aria-label="Show">
<button type="button" class="button secondary small" data-filter="all" aria-pressed="true">All</button>
<button type="button" class="button secondary small" data-filter="stable" aria-pressed="false">Stable</button>
<button type="button" class="button secondary small" data-filter="alpha" aria-pressed="false">Alpha</button>
<button type="button" class="button secondary small" data-filter="game" aria-pressed="false">Game</button>
<button type="button" class="button secondary small" data-filter="launcher" aria-pressed="false">Launcher</button>
</div>
<p class="meta release-count" data-release-count>{{RELEASE_COUNT}}</p>
+3 -3
View File
@@ -1,13 +1,13 @@
// The release notes on the site: the home page's "Recently added" block and the Releases page (every release,
// newest first). Shared by build.mjs (baked into the pages) and the pages' scripts (refreshed from GitHub).
// Tags: the game's vX.Y.Z and the launcher's [email protected] from 0.2, vYYYY.MM.DD[.N] and launcher-vX.Y.Z before
// (src/versions.js). Alpha is a game pre-release, Stable a game release (docs/release-flow.md, "Channels").
// (src/versions.js). One channel (docs/release-flow.md, "One channel"): a release is the game's or the launcher's.
import { markdown, inline, escape } from "./markdown.js";
import { isLauncher, launcherVersion, newestFirst } from "./versions.js";
const REPO = "ludovic111/horizon";
export const channel = (r) => (isLauncher(r.tag_name) ? "Launcher" : r.prerelease ? "Alpha" : "Stable");
export const channel = (r) => (isLauncher(r.tag_name) ? "Launcher" : "Game");
const anchor = (r) => `release-${r.tag_name.replace(/[^\w.-]/g, "-")}`;
// "Horizon 0.2.3: Cave culling, weather" (before 0.2, "Horizon 2026.10.13: …") reads as the version and its line.
@@ -90,7 +90,7 @@ export function recentHtml(list, { rows = 5, picks = 3 } = {}) {
})
.join("");
// What landed last: the "New" items of the newest releases with the highest pull request numbers (a release's
// notes list everything since the last Stable, oldest first).
// notes list everything since the release before it, oldest first).
const pr = (item) => Math.max(0, ...[...item.matchAll(/#(\d+)/g)].map((m) => Number(m[1])));
const seen = new Set();
const picked = game
+2 -3
View File
@@ -630,14 +630,13 @@ code { border-radius: 0; background: var(--well); box-shadow: 0 0 0 1px var(--ou
.page-main > .container > .lede { font-size: var(--text-body); }
.page-main .disclaimer { margin-top: 48px; }
/* Channel and source badges: small square tags. */
/* Release and source badges: small square tags. */
.badge {
display: inline-flex; align-items: center; height: 22px; padding: 0 8px; margin-right: 10px; vertical-align: 2px;
font: 500 12px / 1 var(--pixel); letter-spacing: 0.02em; text-transform: uppercase; white-space: nowrap;
border: var(--block-outline) solid var(--outline); background: var(--well); color: var(--ink-soft);
}
.badge-stable { background: color-mix(in srgb, var(--success) 22%, var(--panel)); color: var(--ink); }
.badge-alpha { background: color-mix(in srgb, var(--warn) 24%, var(--panel)); color: var(--ink); }
.badge-game { background: color-mix(in srgb, var(--success) 22%, var(--panel)); color: var(--ink); }
.badge-launcher { background: color-mix(in srgb, var(--accent) 20%, var(--panel)); color: var(--ink); }
.badge-us { background: color-mix(in srgb, var(--accent) 20%, var(--panel)); color: var(--ink); }
.badge-players { background: color-mix(in srgb, var(--success) 22%, var(--panel)); color: var(--ink); }