ci(release): one-click pre-releases for anyone with write access, and docs/releasing.md #2010

Closed
NotRexed wants to merge 2 commits from ci/prereleases into main
NotRexed commented 2026-11-10 23:31:08 +00:00 (Migrated from github.com)

Releases are too slow: there's a day of merged work on main that no player has. This makes pre-releases a button, and writes the release process down. It's meant to land after #1059, which turns the Windows release job on by default. Before #1059, a pre-release would get the AppImage but no HookshotBuilder.exe.

Actions > Pre-release > Run workflow, for anyone with write access:

  1. It picks the next vX.Y.Z-rc.N for the branch's head (scripts/next_prerelease.sh):
    • main (the default) counts from the patch after the newest stable tag;
    • a release/vX.Y.Z branch uses its own version;
    • N counts up from that version's last pre-release.
  2. It tags the commit and dispatches the Windows builder and Linux AppImage workflows on the tag. A tag pushed with GITHUB_TOKEN starts no workflows, but a workflow_dispatch does, so no personal token is needed.
  3. The tag's release is a prerelease and never "latest" (scripts/release_upload.sh). The in-game update offer and the Builder's update only ask for "latest", so players aren't moved onto it; testers download from the release page.

Options:

  • Dry run only reports the tag it would make.
  • A commit that already has a pre-release is skipped.
  • A nightly run (06:00 UTC) makes one from main while the repository variable PRERELEASE_NIGHTLY is true; it's off unless set.

docs/releasing.md covers:

  • pre-releases, at any time;
  • stable releases: a pre-release that has held up, then the owner's local test, then merging release-please's PR;
  • hotfixes through a release/vX.Y.Z branch, its pre-release, and the stable tag on the tested commit.

Tested: scripts/next_prerelease.sh locally:

Case Result
main v2.0.15-rc.1
release/v2.0.15 v2.0.15-rc.1
A scratch repo, head already tagged rc.1 skip
A scratch repo, after a new commit rc.2

The workflow itself runs for the first time after merge; its dry run is the safe first try.

🤖 Generated with Claude Code

Releases are too slow: there's a day of merged work on main that no player has. This makes pre-releases a button, and writes the release process down. It's meant to land **after #1059**, which turns the Windows release job on by default. Before #1059, a pre-release would get the AppImage but no `HookshotBuilder.exe`. **Actions > Pre-release > Run workflow**, for anyone with write access: 1. It picks the next `vX.Y.Z-rc.N` for the branch's head (`scripts/next_prerelease.sh`): - `main` (the default) counts from the patch after the newest stable tag; - a `release/vX.Y.Z` branch uses its own version; - N counts up from that version's last pre-release. 2. It tags the commit and dispatches the Windows builder and Linux AppImage workflows on the tag. A tag pushed with `GITHUB_TOKEN` starts no workflows, but a `workflow_dispatch` does, so no personal token is needed. 3. The tag's release is a prerelease and never "latest" (`scripts/release_upload.sh`). The in-game update offer and the Builder's update only ask for "latest", so players aren't moved onto it; testers download from the release page. Options: - *Dry run* only reports the tag it would make. - A commit that already has a pre-release is skipped. - A nightly run (06:00 UTC) makes one from main while the repository variable `PRERELEASE_NIGHTLY` is `true`; it's off unless set. **`docs/releasing.md`** covers: - pre-releases, at any time; - stable releases: a pre-release that has held up, then the owner's local test, then merging release-please's PR; - hotfixes through a `release/vX.Y.Z` branch, its pre-release, and the stable tag on the tested commit. **Tested:** `scripts/next_prerelease.sh` locally: | Case | Result | |---|---| | main | `v2.0.15-rc.1` | | release/v2.0.15 | `v2.0.15-rc.1` | | A scratch repo, head already tagged rc.1 | skip | | A scratch repo, after a new commit | rc.2 | The workflow itself runs for the first time after merge; its dry run is the safe first try. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 (Migrated from github.com) reviewed 2026-11-10 23:48:41 +00:00
hckrman101 (Migrated from github.com) left a comment

Nice, a one-click pre-release is a real gap filled. I went through the workflow, the script and the doc against main as it is now (release.yml with release-please from #1059, windows-builder.yml, linux-appimage.yml, release_upload.sh).

What holds up:

  • Trigger: workflow_dispatch plus a schedule gated on vars.PRERELEASE_NIGHTLY. No pull_request* trigger, so a fork or outside PR can't start it; dispatch needs write access.
  • Permissions: contents: read at the top, with contents: write + actions: write only on the one job. persist-credentials: false, and the token is only in env for gh. Least-privilege as far as I can see.
  • Injection: inputs.branch reaches the shell only through $BRANCH. ${{ steps.next.outputs.tag }} is interpolated into run:, but its value comes out of the script's regexes (digits and dots only), so it's safe today (nit inline).
  • Dispatching on the tag works with the build workflows on main: a dispatched run has github.ref = refs/tags/<tag>, so RELEASE_TAG is set, the Windows release job runs (it's on by default since #1059), and release_upload.sh makes it --prerelease --latest=false. release_parts.py previous skips prereleases, so the next builds don't take their setup list from an rc.
  • Races: the prerelease concurrency group serializes runs, and POST git/refs on an existing tag fails (422) under set -euo pipefail, so two runs can't produce the same tag. actionlint passes. The rest of CI is green; Python source checks (windows-2022) is still pending.

Things to fix or decide:

  1. The script's failure is swallowed (inline on line 55). Without shell: bash, a run: step uses bash -e {0} with no pipefail, so next_prerelease.sh | tee is green even when the script exits 1 (for example "no stable vX.Y.Z tag"). The next step then runs with an empty tag: a dry run reports "would tag ... as " and passes, and a real run fails at the refs POST with an unclear error.
  2. branch isn't checked. Any ref is accepted (a feature branch, a tag, a SHA). Anything that isn't release/vX.Y.Z gets main's next vX.Y.0-rc.N, so a typo or a feature branch takes main's next rc number and puts out a pre-release of unreviewed code. A regex guard (^(main|release/v[0-9]+\.[0-9]+\.[0-9]+)$) would match what the input description and the doc say.
  3. Recovering from a half-done run. If the tag is created and then gh workflow run fails (an API blip, say), re-running skips because the head already has an rc tag, and the builds never start. Either dispatch on the skip path when the tag has no release yet, or add the manual recovery to the doc (gh workflow run windows-builder.yml --ref <tag>, the same for linux-appimage.yml).
  4. Stable releases don't have to be the tested commit (inline on docs/releasing.md). release-please tags the merge commit of its release PR, which is main's head at merge time plus the changelog commit. Anything merged after the pre-release ships in the stable release without the soak or the owner's test, so "Nothing becomes stable without that" isn't guaranteed by the flow. The doc should say to merge the release PR only when main hasn't moved since the tested rc (or to cut a fresh rc first).
  5. rc version vs release-please's version (inline on the script). Main's rc's are always the next minor. That matches today's release-as: 2.1.0, but once that's removed, release-please picks patch or minor from the PR titles. A fix-only cycle would put out 2.2.0-rc.N pre-releases followed by a stable 2.1.1. It may be fine to accept that and say so in the doc, or the script could take the version from release-please's open release PR or the manifest.
  6. The nightly runs on a red main. The doc says not to pre-release a branch that doesn't build, but neither the manual nor the scheduled path checks the commit's CI. Since the two builds publish separately, a failed Windows build leaves a pre-release with only the AppImage on it. For the nightly at least, checking the head's check runs first (or documenting that a nightly can be partial) would help.
  7. Worth a line in the doc for testers (not new in this PR): the in-game update check (host/hos_update.c) only checks that the folder's version differs from latest's. A folder built from v2.1.0-rc.1 is therefore offered "A new version of Hookshot is out: v2.0.14" on every launch until they pick "skip this version". That was already true for hand-pushed rc's, but pre-releases become routine with this PR (and nightly ones if the variable is on).

Description: it says main "counts from the patch after the newest stable tag", and the test table shows main -> v2.0.15-rc.1. The second commit (90fa1644) changed that to the next minor (v2.1.0-rc.1), which the doc and the script's header already say. Worth updating the body and the table. The "land after #1059" condition is met now, since #1059 merged today.

Overlap with #1059 / #1062: no conflict. #1059 as merged has only release-please, the build workflow_calls and the publish job. This adds a separate workflow and touches none of those files. #1062 (rolling nightly prerelease) is closed. Its closing note says it was folded into #1059, but the merged #1059 has no nightly or Promote flow, so PRERELEASE_NIGHTLY here is the only nightly path on main. If the rolling nightly comes back later, pick one of the two rather than run both.

Hotfix flow and the rest of the doc match what the workflows do.

Nice, a one-click pre-release is a real gap filled. I went through the workflow, the script and the doc against main as it is now (release.yml with release-please from #1059, windows-builder.yml, linux-appimage.yml, release_upload.sh). What holds up: - Trigger: `workflow_dispatch` plus a `schedule` gated on `vars.PRERELEASE_NIGHTLY`. No `pull_request*` trigger, so a fork or outside PR can't start it; dispatch needs write access. - Permissions: `contents: read` at the top, with `contents: write` + `actions: write` only on the one job. `persist-credentials: false`, and the token is only in `env` for `gh`. Least-privilege as far as I can see. - Injection: `inputs.branch` reaches the shell only through `$BRANCH`. `${{ steps.next.outputs.tag }}` is interpolated into `run:`, but its value comes out of the script's regexes (digits and dots only), so it's safe today (nit inline). - Dispatching on the tag works with the build workflows on main: a dispatched run has `github.ref` = `refs/tags/<tag>`, so `RELEASE_TAG` is set, the Windows `release` job runs (it's on by default since #1059), and `release_upload.sh` makes it `--prerelease --latest=false`. `release_parts.py previous` skips prereleases, so the next builds don't take their setup list from an rc. - Races: the `prerelease` concurrency group serializes runs, and `POST git/refs` on an existing tag fails (422) under `set -euo pipefail`, so two runs can't produce the same tag. actionlint passes. The rest of CI is green; Python source checks (windows-2022) is still pending. Things to fix or decide: 1. **The script's failure is swallowed** (inline on line 55). Without `shell: bash`, a `run:` step uses `bash -e {0}` with no pipefail, so `next_prerelease.sh | tee` is green even when the script exits 1 (for example "no stable vX.Y.Z tag"). The next step then runs with an empty tag: a dry run reports "would tag ... as " and passes, and a real run fails at the refs POST with an unclear error. 2. **`branch` isn't checked.** Any ref is accepted (a feature branch, a tag, a SHA). Anything that isn't `release/vX.Y.Z` gets main's next `vX.Y.0-rc.N`, so a typo or a feature branch takes main's next rc number and puts out a pre-release of unreviewed code. A regex guard (`^(main|release/v[0-9]+\.[0-9]+\.[0-9]+)$`) would match what the input description and the doc say. 3. **Recovering from a half-done run.** If the tag is created and then `gh workflow run` fails (an API blip, say), re-running skips because the head already has an rc tag, and the builds never start. Either dispatch on the skip path when the tag has no release yet, or add the manual recovery to the doc (`gh workflow run windows-builder.yml --ref <tag>`, the same for linux-appimage.yml). 4. **Stable releases don't have to be the tested commit** (inline on docs/releasing.md). release-please tags the merge commit of its release PR, which is main's head at merge time plus the changelog commit. Anything merged after the pre-release ships in the stable release without the soak or the owner's test, so "Nothing becomes stable without that" isn't guaranteed by the flow. The doc should say to merge the release PR only when main hasn't moved since the tested rc (or to cut a fresh rc first). 5. **rc version vs release-please's version** (inline on the script). Main's rc's are always the next minor. That matches today's `release-as: 2.1.0`, but once that's removed, release-please picks patch or minor from the PR titles. A fix-only cycle would put out `2.2.0-rc.N` pre-releases followed by a stable `2.1.1`. It may be fine to accept that and say so in the doc, or the script could take the version from release-please's open release PR or the manifest. 6. **The nightly runs on a red main.** The doc says not to pre-release a branch that doesn't build, but neither the manual nor the scheduled path checks the commit's CI. Since the two builds publish separately, a failed Windows build leaves a pre-release with only the AppImage on it. For the nightly at least, checking the head's check runs first (or documenting that a nightly can be partial) would help. 7. **Worth a line in the doc for testers (not new in this PR):** the in-game update check (`host/hos_update.c`) only checks that the folder's version differs from latest's. A folder built from `v2.1.0-rc.1` is therefore offered "A new version of Hookshot is out: v2.0.14" on every launch until they pick "skip this version". That was already true for hand-pushed rc's, but pre-releases become routine with this PR (and nightly ones if the variable is on). Description: it says main "counts from the patch after the newest stable tag", and the test table shows main -> `v2.0.15-rc.1`. The second commit (90fa1644) changed that to the next minor (`v2.1.0-rc.1`), which the doc and the script's header already say. Worth updating the body and the table. The "land after #1059" condition is met now, since #1059 merged today. Overlap with #1059 / #1062: no conflict. #1059 as merged has only release-please, the build `workflow_call`s and the publish job. This adds a separate workflow and touches none of those files. #1062 (rolling `nightly` prerelease) is closed. Its closing note says it was folded into #1059, but the merged #1059 has no nightly or Promote flow, so `PRERELEASE_NIGHTLY` here is the only nightly path on main. If the rolling `nightly` comes back later, pick one of the two rather than run both. Hotfix flow and the rest of the doc match what the workflows do.
@ -0,0 +41,4 @@
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
BRANCH: ${{ inputs.branch || 'main' }}
hckrman101 (Migrated from github.com) commented 2026-11-10 23:48:41 +00:00

Nothing limits this to main / release/vX.Y.Z. Any other branch (or a tag or SHA, which checkout also accepts) gets main's next rc number. Maybe a first step: [[ $BRANCH =~ ^(main|release/v[0-9]+\.[0-9]+\.[0-9]+)$ ]] || { echo "::error::..."; exit 1; }.

Nothing limits this to `main` / `release/vX.Y.Z`. Any other branch (or a tag or SHA, which checkout also accepts) gets main's next rc number. Maybe a first step: `[[ $BRANCH =~ ^(main|release/v[0-9]+\.[0-9]+\.[0-9]+)$ ]] || { echo "::error::..."; exit 1; }`.
@ -0,0 +52,4 @@
persist-credentials: false
- name: Next pre-release tag
id: next
run: scripts/next_prerelease.sh "$BRANCH" | tee -a "$GITHUB_OUTPUT"
hckrman101 (Migrated from github.com) commented 2026-11-10 23:48:41 +00:00

With no shell: set, this runs as bash -e {0} (no pipefail), so tee hides a failing script: the step passes and outputs.tag is empty. Adding shell: bash here (that's -eo pipefail), or a job-level defaults: run: shell: bash, fixes it.

With no `shell:` set, this runs as `bash -e {0}` (no pipefail), so `tee` hides a failing script: the step passes and `outputs.tag` is empty. Adding `shell: bash` here (that's `-eo pipefail`), or a job-level `defaults: run: shell: bash`, fixes it.
@ -0,0 +57,4 @@
if: steps.next.outputs.skip != 'true'
run: |
set -euo pipefail
tag='${{ steps.next.outputs.tag }}'
hckrman101 (Migrated from github.com) commented 2026-11-10 23:48:42 +00:00

Nit: safe today, because the value is digits and dots from the script's regexes. Passing it as env: TAG: ${{ steps.next.outputs.tag }} would keep every ${{ }} out of run:, which is what the BRANCH input already does.

Nit: safe today, because the value is digits and dots from the script's regexes. Passing it as `env: TAG: ${{ steps.next.outputs.tag }}` would keep every `${{ }}` out of `run:`, which is what the `BRANCH` input already does.
@ -0,0 +64,4 @@
exit 0
fi
gh api -X POST "repos/$GH_REPO/git/refs" -f ref="refs/tags/$tag" -f sha="$sha" >/dev/null
gh workflow run windows-builder.yml --ref "$tag"
hckrman101 (Migrated from github.com) commented 2026-11-10 23:48:42 +00:00

If this or the next dispatch fails after the tag above is made, a re-run sees the head already tagged and skips, so the builds never start. Worth either dispatching on the skip path when gh release view "$tag" finds nothing, or a recovery line in docs/releasing.md.

If this or the next dispatch fails after the tag above is made, a re-run sees the head already tagged and skips, so the builds never start. Worth either dispatching on the skip path when `gh release view "$tag"` finds nothing, or a recovery line in docs/releasing.md.
@ -0,0 +28,4 @@
reports on Discord or GitHub.
2. **The owner tests it**: a full local build from the same commit, played with nothing else running. Nothing becomes
stable without that.
3. Merge release-please's open release PR (`chore: release <version>`), after editing its changelog entry if needed.
hckrman101 (Migrated from github.com) commented 2026-11-10 23:48:42 +00:00

release-please tags the merge commit of its PR: main's head at merge time. If anything landed after the tested pre-release, it goes stable without the soak or step 2's test. Suggest: merge only when main hasn't moved since the tested rc (or cut a new rc and test that).

release-please tags the merge commit of its PR: main's head at merge time. If anything landed after the tested pre-release, it goes stable without the soak or step 2's test. Suggest: merge only when main hasn't moved since the tested rc (or cut a new rc and test that).
@ -0,0 +20,4 @@
stable=$(git tag -l 'v[0-9]*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
[ -n "$stable" ] || { echo "no stable vX.Y.Z tag to count from" >&2; exit 1; }
IFS=. read -r major minor patch <<<"${stable#v}"
version="$major.$((minor + 1)).0"
hckrman101 (Migrated from github.com) commented 2026-11-10 23:48:42 +00:00

Always the next minor. That matches release-as: 2.1.0 now, but after that's removed, release-please may choose a patch (fix-only PR titles). Main's rc's would then be 2.2.0-rc.N before a stable 2.1.1. Fine if intended; worth a sentence in docs/releasing.md either way.

Always the next minor. That matches `release-as: 2.1.0` now, but after that's removed, release-please may choose a patch (fix-only PR titles). Main's rc's would then be `2.2.0-rc.N` before a stable `2.1.1`. Fine if intended; worth a sentence in docs/releasing.md either way.
NotRexed commented 2026-11-11 00:02:06 +00:00 (Migrated from github.com)

Closing in favour of #2018 (hckrman101): it does the same with release-please itself. Merging its release PR publishes a release candidate (vX.Y.Z-rc.N), Promote publishes a tested one, and a rolling nightly of main covers the rest. Two RC schemes side by side would collide, as #2018 explains. Main's history since v2.0.14 has 29 feat commits, so its first candidate is 2.1.0-rc.1, the owner's chosen next version.

Closing in favour of #2018 (hckrman101): it does the same with release-please itself. Merging its release PR publishes a release candidate (`vX.Y.Z-rc.N`), Promote publishes a tested one, and a rolling nightly of main covers the rest. Two RC schemes side by side would collide, as #2018 explains. Main's history since v2.0.14 has 29 `feat` commits, so its first candidate is 2.1.0-rc.1, the owner's chosen next version.
Commenting is not possible because the repository is archived.
No description provided.