ci(pr-builds): build pull requests that are not drafts, and publish them signed for testers #353
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!353
Loading…
Reference in a new issue
No description provided.
Delete branch "gab9281/Longshot:ci/pr-builds"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
Testers can only try a pull request today by checking it out and building it from source, so most of the hardware we need (AMD, Steam Deck, 4–8 GB cards, Windows) never runs a change before it is merged. This is the CI half of #337: every pull request that is not a draft gets its builders built, signed with a key of its own and published, so the Longshot Launcher can offer it to testers.
What changed
.forgejo/workflows/pr-build.yml, onpull_request(opened, a push, reopened, edited; Forgejo's payload calls a pushsynchronized, GitHub'ssynchronize: both build, and both cancel the build before). A pull request that is not a draft (noWIP:or[WIP]title) builds its head withbuild-linux.ymlandbuild-windows.yml, namedpr-<number>-<first 8 hex of the head>, as the run's artifacts. They build as a pull request does:build-linux.ymlandbuild-windows.ymlgainRELEASE_BUILD, set for a channel's version only, so apr-tag names the files but takes the pull request caches and never writes the release-only download cache. It is the pull request's own run, a fork's: a read-only token and no secret, so nothing in it is signed or published. A new push cancels the build before it. An edit builds only a head with no build yet: takingWIP:off builds once, and editing the description afterwards builds nothing (Forgejo's payload doesn't say what was edited, sonameasks the runs and releases API; a run that passed counts only with its builders' artifacts, since an edit's run that built nothing passes too)..forgejo/workflows/pr-publish.yml, every 15 minutes and by hand. It checks out main and runs no pull request's code. For each open, non-draft pull request whose head has apr-build.ymlrun that passed with both builders, it takes the two files from that run's artifacts andtools.lockfrom the head, writesSHA256SUMS, signs it withPR_SIGNING_KEY(neverRELEASE_SIGNING_KEY), checks it againstscripts/release_keys.json'spull_request_keys, and publishes a pre-release on no channel throughrelease.py publish. A draft a failed publish left is published again by the next run; any error fails that build alone, and each build's folder is removed after it. The pull request's title goes on the release page as a code span, never as Markdown. Then it prunes: the builds of pull requests closed, merged or back to draft (the open ones read page by page until an empty page), each one's past its newest 2, and drafts a failed run left over 6 hours (their age with the server's offset). Without the secret it publishes nothing and says so in the run. Key or not, it ends by counting who has looked at each pull request that is not a draft (pr_builds.py counts: the people other than its author who commented on it or reviewed it, bots left out) into the releasepull-requests(a pre-release on no channel, never a version; its one filepull-requests.jsonreplaced each run), which the launcher's picker orders by: fewest people first, then the oldest.scripts/pr_builds.py(name,pending,publish,prune,counts,verify), on top ofrelease.py's API, signing, publishing and download (release.downloadgainsto=, streaming a large file and leaving none of it behind when it fails), andcheck_pr_description.py's draft rule. An artifact's zip gives up exactly the one file expected, never a path.scripts/release_keys.jsongainspull_request_keys(empty: no key yet), andpr_builds.pyrefuses a list that shares a key with the release keys.docs/releases.md, "Pull request builds" (the same text as the launcher's copy, in its branchfeat/pr-builds), and the CI table;RELEASING.md, "The pull request key (Longshot)", the one-time setup.tests/test_pr_builds.py: the tag an event builds (an edit's run that built nothing is no build), the pending builds (a draft left is published again), every page of open pull requests, one failing build leaving the others to go on, the title as text, the prune (never anything but apr-build; a draft's age with the server's offset), the one file taken from an artifact, signing with a pull request key and never a release key, who has looked (the author, bots and deleted accounts are no one; a review counts), the counts never pruned, that a push issynchronizedorsynchronize, that apr-tag is no release build in either builder, and that the build run has no secret while the publishing run checks out main alone.Flags and settings
PR_SIGNING_KEYpr-publish.ymlsigns pull requests' builds with itrelease_keys.jsonand the launcher (RELEASING.md, The pull request key)release_keys.jsonpull_request_keysReleaseKeys.PullRequestBuiltIn)Measurements
main:
cf605c85(2026-11-13)this PR: ci/pr-builds @
ed0d385eHardware: none: CI only (Forgejo's runners; the local checks on Linux)
Scene: none: CI only
Method: the change's game-free checks (
tests/test_pr_builds.py, and the checks that read the files it changes: release, Windows-on-Linux, PR description, AppImage package retry and player build, Linux Docker packages, recomplauncher.json, no release lookup, tools.lock), actionlint 1.7.12 with.forgejo/actionlint.yaml, and read-only runs ofpendingandprune --dry-runagainst git.magicbean.dev;tests/run_all.pyon Linux, and Forgejo CI on this pull request.How to reproduce:
python -I -B tests/test_pr_builds.py;python -I scripts/pr_builds.py pending;python -I scripts/pr_builds.py prune --dry-runcf605c85ed0d385etests/test_pr_builds.pytest_release.py,test_windows_on_linux.py,test_check_pr_description.py,test_appimage_package_retry.py,test_appimage_player_build.py,test_linux_docker_packages.py,test_recomplauncher_json.py,test_no_release_lookup.py,test_tools_lock.pytests/run_all.py(Linux, 16 at once)test_module_ids.py,test_pad_layout.pyfail (only these two run on main)pr_builds.py counts --dry-run(live, read-only)pr-build.yml's first real build (run 4600, atae6c238)ci.yml: the title and light checks;pr-description.yml;third-party.yml;pr-build.yml, a draft so nothing built)pr_builds.py pending(live, read-only)pr-build.ymlrun yet, so nothing to publishpr_builds.py prune --dry-run(live, read-only)Testing
Tested: the checks above on Linux.
pendingandprune --dry-runagainst the live API: the runs filter (workflow_id,head_sha) answers in under a second, and run payloads carrypull_request.draft.pr-build.ymlon this pull request: its run at8a6095bcbuilt nothing (Forgejo'ssynchronized, fixed inae6c238), and its run atae6c238built both builders as artifacts (run 4600).Not tested:
pr-publish.ymlruns only from main, so its first run is after the merge (the counts release included), and publishes nothing untilPR_SIGNING_KEYis set. The artifact download (/actions/artifacts/{id}/zip) andrelease.py publishwith a fork's head astarget_commitishhave not run against the server yet. Nothing in the game changes.Needs other testers: None: the launcher side, which installs these builds, is its own pull request, MagicBeanDev/LongshotLauncher pull request 59, which requires this one.
Screenshots
Nothing on screen.
Risk and rollback
pr-build.ymlstops it.testing wantedby default. A fork's run Forgejo holds for approval builds nothing until a maintainer approves it.pr-pre-releases are on no channel (release.py'snewestand the launcher's ignore them), and the prune touches onlypr-<number>-<8 hex>tags.pr-releases already published can be deleted withpruneon an empty list of pull requests.Reviewer notes
Start with
scripts/pr_builds.pynameandpending, then the two workflows. The key itself (keygen, the secret, the launcher's built-in list) is a maintainer's step, on purpose: RELEASING.md, The pull request key.Issues
See #337
Checklist
For everyone
type(scope): description).main. (main moved to0caf0618after this was opened; the branch merges onto it without a conflict, and the rebase waits for a force push to the fork)python -I -B tests/run_all.pyrun and passing (any failure that also fails onmainis named above). (on Linux ated0d385e: 495 passed, 2 failed, 63 skipped; the two,test_module_ids.pyandtest_pad_layout.py, fail on main atcf605c85too, named under Measurements)third_party/untouched (a new folder with aPROVENANCE.mdinstead).If applicable
Assisted-by:/Co-Authored-By:trailers.tests/that reads changed source updated in this PR, not weakened. (test_pr_builds.pyreadsbuild-linux.ymlandbuild-windows.yml, changed here; the other checks that read them pass unchanged)🤖 Generated with Claude Code
fe7a5029applies a code review's findings. A pr- tag no longer builds in release mode: the builders take the pull request caches, and never write the release-only download cache. pr_builds.py: an edit's run that built nothing no longer counts as a build; a draft left by a failed publish is published again; open pull requests are read until an empty page, so prune never takes an open one for closed; one build's error no longer stops the others, and each build's folder is removed; drafts' ages use the server's offset; the title is a code span on the release page; the download is release.download's. 28 tests (was 21), the checks that read the changed files, and actionlint pass; CI passed onfe7a5029. run_all.py has not run yet (see its box).tests/run_all.py on Linux at
fe7a5029: 495 passed, 2 failed, 63 skipped. The two failures, test_module_ids.py and test_pad_layout.py, fail on main atcf605c85too and read no file this PR changes. run_all box ticked.WIP: ci(pr-builds): build pull requests that are not drafts, and publish them signed for testersto ci(pr-builds): build pull requests that are not drafts, and publish them signed for testersThe launcher side is open: MagicBeanDev/LongshotLauncher#59 (the pull request picker, filtered to testing wanted by default). It requires this one.
0c904244: docs/releases.md, identical to the launcher's copy (MagicBeanDev/LongshotLauncher#59): the picker never lists a draft, and its letters-in-order search works within one word. Docs only.Since
fe7a5029:8a6095bc, download(to=) leaves no partial file;ae6c238, Forgejo calls a pushsynchronized(notsynchronize), so a push built nothing, now it builds and the first real build passed (run 4600: AppImage, Builder zip, Windows back end);ed0d385e,pr_builds.py countspublishes who has looked at each pull request (releasepull-requests), which the launcher (MagicBeanDev/LongshotLauncher pull request 59) orders by. 32 tests; run_all.py ated0d385e: the same 2 failures as main. Not rebased yet on main's0caf0618(clean merge): the box says so.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.