Repository navigation
ci: label-gated hardware tests on testers' self-hosted runners, through the owner's sandbox - #2807
Joey-Magnus wants to merge 4 commits into
Conversation
…gh the owner's sandbox (NotRexed#2797) Co-authored-by: Cursor <cursoragent@cursor.com>
…uld pick one here); build apart from the run Co-authored-by: Cursor <cursoragent@cursor.com>
mooki000
left a comment
There was a problem hiding this comment.
Thanks for turning the label idea into a pull request this fast. I reviewed ff236c98 as the workflow the fork pilot will run under. Four findings, the first one the most practical:
-
The upstream-to-fork route is missing.
hardware-test-label.yml:28,40dispatches ingithub.repository, so an upstream label starts an upstream run. Its runner job cannot use a repository runner registered on a tester's fork. Manually dispatching the fork with the upstream PR number also does not bridge it:hardware-test.yml:49,57,82,110,133checks, fetches and reports in the fork. A fork-local test PR works; automatic testing of an upstream PR needs an authorized relay and separate execution-repository/source-PR identities. The default token is repository-scoped. Please either implement that route or document the fork-local-only pilot explicitly; registering runners upstream is not the fix. -
Recheck approval after the runner queue.
hardware-test.yml:57–61checks the head/label before the hardware job can wait up to 24 hours;77–82then calls the sandbox without rechecking. A removed label, closed PR or moved head during the wait is not caught. The sandbox contract should require a trusted fail-closed recheck before exposing the dump, and again before GPU acquisition after a long build. This is stale approval for the old pinned SHA, not substitution of an unreviewed new SHA. -
A delayed synchronization job can remove a newer approval.
hardware-test-label.yml:58–61reads and deletes live labels. A queued cleanup for push A can remove a label added after review of B—or after review of the same head while cleanup was delayed. Skip obsolete-head events and distinguish label applications newer than the triggering push; an event-SHA check alone does not cover the same-head case. -
Cap the escaped comment, not just its input. At
hardware-test.yml:126, escaping expands the bytes afterhead -c 60000. With a fakegh, 60,000@bytes produced 120,268 comment characters / 240,268 UTF-8 bytes including the wrapper. Reserve space for the wrapper and cap after escaping. Also add an explicit newline before the closing fence: a file without a final newline currently joins that fence to its content. No API comment or rendered-Markdown test was performed.
The ordinary fence/mention case is handled, and I found no label/branch-name shell injection: environment variables, quoted expansions and validation before jq interpolation are the right pattern. The reporter does not execute the result. The missing actions: read permission is not a demonstrated problem for the downloader's same-run mode.
Two contract clarifications: permissions: {} removes configurable repository-token permissions, not the runner's own session/artifact credentials; those must remain outside the workload. Also, the full results.txt/PNG artifact uploads before comment truncation, without size/type checks, and the pinned uploader follows symlinks. The trusted owner-side exporter must enforce regular files, content allow-listing and byte/count caps. No-network execution does not remove this publication channel.
The tests cover the immediate snapshot gate and a short report, but not repository identity (GH_REPO), synchronization races, acquisition-time revocation, output expansion or sandbox/export behavior. A manual default-branch guard and an explicit Linux/Bash scope would also make the first pilot clearer.
Where the pilot stands: I have a draft of a Linux reference program written to this invocation contract (it exports only bounded, regenerated text at first). It stays fail-closed for now: the existing local GPU guard needs cross-user/namespace-aware integration, and the build gate must be entered from a trusted neutral directory so its relative shader hook cannot execute PR code outside confinement. Nothing is registered and no game has been started under it; the cases I ran against it are authored fakes, not a sandbox or GPU proof. For the pilot itself I will use the route that works as written: a fork-local test pull request on my fork. I will tag you when the program and the workflow run are up.
No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…fe label cleanup, comment capped after escaping The pilot is fork-local only: the label, the test pull request and the runner are all on one tester's fork. The workflow headers and docs/hardware-runners.md say so, and that upstream labels do not reach a fork's runner and registering runners upstream is not the fix. The gate dispatches only for hardware names listed in the repository's HOOKSHOT_HW_RUNNERS variable; without it a label leaves a notice and starts nothing. The runner job checks again, after the queue and before calling the sandbox, that the pull request is open, still carries the label and is still at the pinned commit, through the public API (the runner has no token), and stops on anything else. The sandbox contract asks for the same check before the dump goes in and before taking the GPU. The label cleanup does nothing when its push is no longer the head, and keeps a label whose latest labeled event is newer than the push; if the label events cannot be read, every label comes off. The report escapes first and caps after, at 60,000 bytes, drops a character the cut split, puts a newline before the closing fence and says when it cut. This also fixes actionlint's SC2016 on that step. Manual runs must come from the default branch; the runner job is Linux and Bash. The doc adds that the exporter puts only capped regular files in --out, and that the runner's own credentials stay outside the workload. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
Thanks @mooki000, all four points are addressed in 31e6759, on top of a merge of 1. Upstream-to-fork route. No relay: the pilot is now explicitly fork-local. The headers of both workflows, a new "For now: a pilot on one fork" section in 2. Recheck after the queue. The runner job now has a first step, before the sandbox, that reads the PR again through the public API (the runner still has no token) and stops unless it is open, still at the pinned SHA and still labelled 3. Delayed cleanup. The cleanup reads the head and labels in one call and does nothing (with a notice) if its push is no longer the head. Otherwise it reads the issue events and keeps any label whose latest 4. Comment cap. The report escapes first, then cuts to 60,000 bytes, drops a UTF-8 character the cut split, always puts a newline before the closing fence, and says when it cut. With the stand-in Clarifications. Manual dispatch from any branch but the default one stops in Tests. Still open. The sandbox and the GPU side are your work in progress, and so is a real pilot run on a runner: nothing here has run on a self-hosted runner, against a real sandbox, or posted a real comment through the API. No upstream relay, by design for now. (_trash.panda's agent) |
mooki000
left a comment
There was a problem hiding this comment.
Thanks for the quick turn. I read 31e67594 whole (both workflows) and ran tests/test_hardware_test_workflow.py at that commit on Linux: 31 pass (bash 5.3; jq on this PC is jaq 3.1, so the after-queue filter works with that too). All four points are closed as I read them:
- Fork-local. The gate dispatches only for names in
HOOKSHOT_HW_RUNNERS, so a label here leaves a notice and queues nothing; the headers and the doc say where the pilot lives. - After the queue. The runner's first step stops unless the pull request is open, at the pinned commit and still labelled, and on any answer it cannot read; the contract has the sandbox repeat it.
- Cleanup. Head and labels come from one read, a label whose latest
labeledevent is after the push stays, and unreadable events take every label off. - Comment. Escape, then cut, then drop a split character, newline before the fence, and a note when cut. I also tried runs of five and seven backticks through the escape: no line is left that could close the fence.
Two small observations, neither blocking:
- Cleanup when the head has moved again (the
head != SHAearly exit in the label workflow): the older push's run leaves everything to the newer push's run. If that one fails on a read or never starts, a label from before both pushes stays on. The timestamp rule already makes it safe for the older run to carry on, since it only takes off labels put on before its own push, and those are older than any later push too; so dropping the early exit loses nothing. The harm today is small either way: only alabeledevent dispatches, and the after-queue check compares the head. - The recheck without a token draws on the public API's allowance for anonymous reads (60 an hour per address), and the contract has the sandbox read twice more. Stopping when it cannot read is right; one line in the doc would save a tester wondering why a run stopped with "could not read" after a busy hour.
On my side: the Linux reference sandbox is being brought to the contract at this head (HOOKSHOT_HW_HARDWARE, the two repeat checks from outside the sandbox, regular files only in --out), with tests for the hostile cases. It goes on #2797 when those pass, and no runner gets registered before then.
Nitnek
left a comment
There was a problem hiding this comment.
I independently replayed the six changed files at 31e6759 on Linux: python3 -I -B tests/test_hardware_test_workflow.py returned 31 tests, OK, exit 0. This was a game-free replay with the fixture's local gh/curl stand-ins, not a self-hosted runner, sandbox or GPU run. The assertion about other workflows is limited to the collected workflow files, not a full-repository security approval. I am not repeating mooki's findings on the older revision as if they were still open.
One small result-contract follow-up for the pilot, separate from the workflow corrections: keep orchestration outcome and functional verdict explicitly separate. I also replayed the current Comment script with a recording gh double and OUTCOME=success:
results.txtcontainingSYNTHETIC required check scene_complete: FAILproduces the**success**header and preserves the FAIL text below it.- No
results.txtproduces the same**success**header followed byThe runner wrote no results.txt.
That is consistent with the documented job-outcome reporting; I am not calling it a demonstrated false hardware PASS or a lost failure. It does mean that a consumer must not turn the bold job status into a test verdict.
For a later machine-readable receipt, a conservative rule is: validate repository/PR/exact SHA/hardware/scene identity and schema first; take required check names from a separate trusted plan; a known workload failure, fatal/device-loss observation or required FAIL dominates incomplete evidence; missing/SKIPPED/UNKNOWN checks stay INCONCLUSIVE; PASS requires every named required check PASS and complete, clean process evidence. That is still an untrusted self-report, not authentication or isolation. The report should never execute receipt content.
No runner registration, workflow dispatch, real game data, sandbox qualification or GPU result is implied by these replays. This is a COMMENT-level verification/follow-up, not a general security approval.
— Nitnek, with AI-assisted analysis
|
CI follow-up for head I read the actual job logs, not just For Both fail because The Linux job has a separate failure in Logs inspected:
This was source/log analysis and a game-free Linux text-fixture replay only, not a Windows rerun, full suite, workflow dispatch, sandbox or GPU qualification. No blanket green-CI or approval claim. AI-assisted analysis: Hermes Agent (OpenAI GPT-6.1-sol). |
Testers want PRs tested on their own hardware automatically (#2797), and Dr.Crow suggested starting those tests with a label. This PR adds that trigger, with security rules a PR can't get around, so runners can be added later without exposing testers' PCs or their game dumps.
For now it's a fork-local pilot. The label, the PR being tested and the runner are all on one tester's fork: the PR is a test PR opened on the fork itself. The workflows dispatch, check and report in the repository they run in, with its own token, so a
hardware:label on a NotRexed/Hookshot PR does not reach a fork's runner (here it only leaves a notice), and registering runners on NotRexed/Hookshot is not the fix. Testing upstream PRs automatically would need an authorized relay, which is not built.What it adds
.github/workflows/hardware-test-label.yml(the gate). When a maintainer adds ahardware: <name>label (for examplehardware: steam-deck), it startshardware-test.ymlfrommain, for the exact commit that was labelled, but only if<name>is listed in the repository'sHOOKSHOT_HW_RUNNERSvariable; otherwise it leaves a notice pointing at the fork pilot. New commits take everyhardware:label off, with a comment, so a maintainer looks again before the next run. A cleanup that runs late does nothing if a newer push came since, and leaves a label that was put on after its push. It never checks out the PR. That's whypull_request_targetis safe here, the same reasoning aspr-title.yml..github/workflows/hardware-test.yml(workflow_dispatchonly, from the default branch only):check(GitHub's runner): the commit must still be the PR's head and still carry the label.run(the tester's Linux runner,[self-hosted, linux, hookshot-hardware, <name>], Bash):permissions: {}and no checkout. After the queue it checks again, through the public API, that the PR is open, labelled and at that commit, and stops if not. Then it calls the owner's sandbox program (HOOKSHOT_HW_SANDBOX) and refuses to run if there isn't one. The sandbox fetches the commit, cuts the network, builds and runs with the dump read-only, and writes onlyresults.txtand screenshots.report(GitHub's runner): posts the outcome andresults.txton the PR as quoted text. The runner's text can't close the code fence or @mention anyone, and it's cut at 60,000 bytes after escaping, so the comment stays under GitHub's limit. It never runs anything the runner produced.docs/hardware-runners.md: the fork-local pilot, how a test starts, and what a runner owner must set up: a throwaway VM, the approval checked again before the dump goes in and before taking the GPU, no network after fetching the commit, the dump read-only, an exporter that puts only capped regular files in--out, no credentials (the runner's own session credentials stay outside the workload), and ephemeral runners. It's linked fromdocs/test-requests.md.tests/test_hardware_test_workflow.py: game-free checks that keep those rules in place:${{ }}inside any shell script, and every action is pinned to a commit.ghandcurl. Bad labels (x$(id), spaces,a;b, too long) and hardware the repository has no runner for dispatch nothing. A label likebugdoesn't count as hardwarebug. A moved head, a missing label or a manual run from another branch stops the run, and so does a closed PR, a moved head or a removed label found after the queue. A late cleanup leaves newer labels and newer pushes alone. Injected fences and @mentions are neutralized, 60,000@bytes stay under the limit, and a file without a final newline still closes the fence..github/actionlint.yaml: declares the custom runner label, soactionlintpasses.Not in this PR
pull_requestworkflow, past the label and the sandbox (mooki's research comment on Idea: hardware test runners, so an approved PR test runs on a Steam Deck, AMD, Intel or 8 GB card without waiting for a person #2797). Putting them here would need runner groups limited to this workflow, which a personal account's repository doesn't have, and is the maintainers' call.hardware:labels still need creating, one per runner type, on the fork that runs the pilot.Checks
python -I -B tests/run_all.py: everything passes excepttest_lift_cacheandtest_updater_asset_digest, which fail the same way onmainon this machine. The new test passes (31 checks).actionlint1.7.12 andshellcheck0.11.0 on both workflows: clean.