Skip to content

ci: fork-safe benchmarks via image artifacts (replaces #113)#115

Merged
dionhaefner merged 5 commits into
mainfrom
fix/revert-113-unbreak-main
Jul 22, 2026
Merged

ci: fork-safe benchmarks via image artifacts (replaces #113)#115
dionhaefner merged 5 commits into
mainfrom
fix/revert-113-unbreak-main

Conversation

@dionhaefner

Copy link
Copy Markdown
Contributor

Summary

#113 broke main: its trusted workflow_run executor checked out the PR's fork code (ref: <head SHA>) to build it, but actions/checkout@v6 refuses a fork PR checkout in a workflow_run context by default (the "pwn request" guard), so the build job failed for every solver-changing PR. Opting in (allow-unsafe-pr-checkout: true) would run fork-authored build_base.sh/Dockerfile with the base repo's packages: write token — exactly the vulnerability the guard exists to prevent.

This PR reverts #113 and replaces it with a much smaller, fork-safe flow that never executes fork code in a trusted context and never requires a fork to push to (or privately read from) GHCR.

How it works now

Everything stays on the pull_request trigger:

  • Build (PR path): builds the changed solver, docker saves it, and uploads a solver-image-<name> artifact instead of pushing to GHCR. The GHCR push path is retained only for push-to-main / dispatch (which publishes the baseline :latest / :<sha> images).
  • Run: docker loads those artifacts back to <name>:latest — the exact local tag mosaic run --no-build resolves — then pulls only the unchanged solvers from public GHCR at the PR's base SHA, skipping the loaded ones (pull-solver-images.py --skip-images).
  • bench-ok stays a normal job-based merge gate (no branch-protection change). Docs RTD/preview + PR comment continue via the existing docs-publish.yml (workflow_run).

Fork code only ever runs in the untrusted pull_request context (read-only token, no secrets) — the build produces an artifact, the run only consumes images — so no trusted workflow_run, commit-status gating, or handoff is needed.

Required setup (done)

The mosaic/* GHCR packages must be public-read so fork PRs can pull base images and warm the build cache-from. This has been flipped to public. Write stays maintainer-only (unchanged).

Verification

  • actionlint clean (only pre-existing legacy shellcheck/head.ref nits, none in new code).
  • Local proof of the tag seam: docker save <name>:latestdocker load reproduces exactly <name>:latest.
  • Two rounds of adversarial review; the second confirmed the tag chain against config.py/runner.py and found no correctness bugs.
  • workflow_run/artifact behavior can only be fully exercised once on main; after merge, re-run a fork PR (e.g. perf: upgrade INS.jl tesseract to v5.0.0 (native ν gradient, in-place forward, float32) #103) to confirm end-to-end.

🤖 Generated with Claude Code

dionhaefner and others added 5 commits July 21, 2026 22:47
…)"

This reverts commit ad4353a.

#113's trusted `workflow_run` executor checked out and built the PR's
solver code via `actions/checkout` with `ref: <PR head SHA>`. In a
`workflow_run` context actions/checkout@v6 refuses that by default (the
"pwn request" guard), so the build job fails for every solver-changing
PR — main is broken. Opting in (`allow-unsafe-pr-checkout: true`) would
run fork-authored build_base.sh / Dockerfile with the base repo's
packages:write token, which is the exact vulnerability the guard
prevents. Revert to the working pre-#113 flow; a fork-safe redesign that
never executes fork code in the trusted context follows separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the benchmark image flow so fork PRs work without ever pushing to
or privately reading from GHCR, and without executing fork code in a
trusted context (the pitfall that broke #113):

- build-tesseracts.yml: on the PR path (workflow_call) the built image is
  `docker save`d and uploaded as a `solver-image-<name>` artifact instead
  of pushed to GHCR. The push path is retained only for push-to-main /
  dispatch, which publishes the baseline `:latest` / `:<sha>` images. GHCR
  login is skipped on the PR path (no auth needed to save an artifact).

- benchmark-run.yml: the run job downloads the `solver-image-*` artifacts
  and `docker load`s them back to `<name>:latest` — the exact local tag
  `mosaic run --no-build` resolves — then pulls only the *unchanged*
  solvers from GHCR at the base SHA. GHCR is public-read, so this works
  from fork PRs; login is best-effort (rate limits only).

- pull-solver-images.py: add --skip-images so the registry pull skips the
  changed solvers already loaded from artifacts (they were never published
  at the base SHA). Drop the now-unused --tag/image_tag plumbing.

Everything stays on the pull_request trigger. The build runs fork code in
the untrusted context (read-only token, no secrets) — harmless — and the
run only consumes images, so no trusted workflow_run, commit-status
gating, or handoff is needed. bench-ok remains a normal job-based gate;
docs RTD/comment continue via the existing docs-publish.yml workflow_run.

Requires the mosaic/* GHCR packages to be public-read (one-time org
setting) so fork PRs can pull base images and warm the build cache-from.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The build and benchmarks jobs call reusable workflows that reference
secrets.GITHUB_TOKEN (docker/login-action). Dropping `secrets: inherit`
left that reference unsatisfied, so GitHub rejected the whole run at
graph-resolution time (startup_failure, no jobs, no logs). Restore
`secrets: inherit` on both calls — matching the working baseline. The
earlier secret-minimization was defense-in-depth for the abandoned
trusted-context design and isn't needed here (no fork code runs with
elevated scope in this flow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two errors made every Benchmark run on this branch fail at graph
resolution (startup_failure, no jobs, no logs):

1. The `build` caller job dropped `packages: write`, but the reusable
   build-tesseracts.yml declares `packages: write` at the workflow level.
   A reusable workflow can't be granted less than it declares, so the run
   was rejected before starting. Restore `packages: write` on the caller
   (unused on the PR path, which never pushes, but required to satisfy the
   contract).

2. The publish-vs-artifact split gated on `github.event_name !=
   'workflow_call'`, but inside a called workflow `github.event_name` is
   the caller's event (pull_request / push), never 'workflow_call' — so
   the gate was always true and PRs would have tried to push. Gate on
   `inputs.matrix` instead: it is set only when benchmark.yml calls this
   for a PR, and empty on the push-to-main / dispatch publish path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dionhaefner

Copy link
Copy Markdown
Contributor Author

Note on the red bench-ok status ("Could not recover benchmark handoff")

That status is expected and non-blocking. It's posted by the old benchmark-execute.yml (from #113) that still lives on main: workflow_run always runs the workflow definition from the default branch, so main's stale executor fires on this PR's Benchmark run, finds no handoff artifact (this PR removes that whole mechanism), and posts bench-ok=error.

bench-ok is not a required status check — the required ones are All checks passed (CI) and Benchmark checks passed (the reverted job), both green here. Merging this PR deletes benchmark-execute.yml from main, after which the spurious bench-ok error stops appearing on all PRs.

@dionhaefner
dionhaefner merged commit 862f94e into main Jul 22, 2026
13 of 14 checks passed
@dionhaefner
dionhaefner deleted the fix/revert-113-unbreak-main branch July 22, 2026 11:21
@pasteurlabs pasteurlabs locked and limited conversation to collaborators Jul 22, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant