Skip to content

ci: skip eval jobs deterministically on fork PRs#2345

Open
andrey-esipov wants to merge 3 commits into
garrytan:mainfrom
andrey-esipov:ci/fork-pr-eval-skip
Open

ci: skip eval jobs deterministically on fork PRs#2345
andrey-esipov wants to merge 3 commits into
garrytan:mainfrom
andrey-esipov:ci/fork-pr-eval-skip

Conversation

@andrey-esipov

Copy link
Copy Markdown
Contributor

Problem

Fork PRs cannot pass this workflow, and today whether they appear to pass is decided by Docker layer cache state rather than by the code under review.

GitHub does not pass repository secrets to a pull_request triggered from a fork, and issues a read-only GITHUB_TOKEN (fork workflow security). Three consequences follow, none of which any contributor can fix from a branch:

  • evalsANTHROPIC_API_KEY, OPENAI_API_KEY, and GEMINI_API_KEY are all empty. Agent shards fail with error_api; llm-judge throws Could not resolve authentication method at new Anthropic() before a request is made; e2e-pty-plan-smoke shows Not logged in and then times out at 300s. No model executes, so these burn 0 tokens and $0.00 across every retry.
  • report — cannot post its comment: Resource not accessible by integration (addComment).
  • build-image — cannot push to ghcr.io with packages: read.

The nondeterminism

evals declares needs: build-image, and the image tag is content-hashed over Dockerfile.ci, package.json, and bun.lock. So:

Cache state on a fork PR build-image evals PR appears
Image already cached passes (no push needed) runs, fails 7 shards + report ❌ red
Image not cached fails (push denied) skipped mostly green

Identical code, opposite verdicts. #2264 merged in the second state with build-image red and evals showing skipping; #2323 sits in the first state with 8 red checks. Neither outcome reflects the branch.

This also means no fork PR can reach green while these jobs run. e2e-pty-plan-smoke does not use diff-based test selection (shouldRun = EVALS && EVALS_TIER === 'gate'), so it executes on every PR regardless of the diff, and it needs an authenticated claude. Confirmed across unrelated fork PRs from different authors, which fail with byte-identical errors.

Change

Gate build-image, evals, and report on the PR head living in the base repository:

if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository

Fork PRs now skip these jobs deterministically — the same outcome the cold-cache path already produces by accident, but for a stated reason and every time. Same-repo PRs, pushes, and workflow_dispatch are untouched and keep full eval coverage. Maintainers get real coverage on fork contributions the way they do today: push the commits to a base-repo branch (git fetch origin pull/N/head then push that ref) and let it run with secrets.

What this does not do

  • Does not weaken coverage where coverage was real. These jobs currently produce no signal about fork branch code — they fail before any model or push is attempted.
  • Does not add skip-on-missing-key to the test harness. That would silently weaken same-repo runs, and it contradicts existing policy (test/skill-e2e-first-task-scaffold.test.ts throws requires ANTHROPIC_API_KEY ... refusing to skip).
  • Does not use pull_request_target. Running untrusted fork code with secrets is unsafe.
  • Does not fix build-image push behavior generally. Making the build run with push: false on forks so Dockerfile.ci still gets compile-checked is a reasonable follow-up, deliberately out of scope here.

Verification

  • actionlint (v1.7.11, matching .github/workflows/actionlint.yml) reports the same 2 pre-existing runner-label warnings before and after; no new findings.
  • Workflow parses cleanly; job graph and needs edges unchanged.
  • Diff is 3 if: conditions plus a comment. No step, matrix, secret, or permission changes.

Note for maintainers

If Trunk requires the evals (...) or report contexts for merge, please confirm skipped jobs satisfy them as expected. I could not inspect the Trunk configuration from outside the org. Happy to adjust the approach.

Found while investigating #2323; filed separately so that PR stays free of CI edits.

Fork PRs cannot receive repository secrets or a writable GITHUB_TOKEN, so the
eval shards, the report comment, and the ghcr.io image push can never succeed
from a fork. Today the outcome depends on Docker layer cache state rather than
on the code: a warm cache lets build-image pass, the eval shards then run and
fail at SDK auth; a cold cache makes build-image fail its push and the evals
skip entirely. Identical code produces opposite CI verdicts.

Gate build-image, evals, and report on the PR head being in the base
repository. Same-repo PRs, pushes, and workflow_dispatch are unchanged and
keep full coverage.
@trunk-io

trunk-io Bot commented Jul 25, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

The first cut gated build-image, evals, and report on the PR head being in the
base repo. That skipped every job in the workflow, and a run in which no job
starts is recorded as a startup failure (conclusion: failure, zero jobs) rather
than a skip -- trading one bogus red for another.

Let build-image always run instead, and make only its push conditional. A fork
still builds the CI image, so Dockerfile.ci changes stay validated, it just does
not publish with a packages:read token. That guarantees at least one job starts
while evals and report skip deterministically.
The previous comment claimed an all-skipped run registers as a startup
failure. That is not the mechanism -- an all-skipped run is simply grey.
The real reason build-image stays ungated is that fork PRs should still
validate Dockerfile.ci and get one honest green check.
@andrey-esipov

Copy link
Copy Markdown
Contributor Author

Heads up: this PR's own checks cannot run, and that is expected

E2E Evals and Windows Free Tests on this branch both completed with 0 jobs and startup_failure ("This run likely failed because of a workflow file issue"). I chased that for a while assuming I'd broken the YAML. I hadn't.

The tell is Windows Free Tests. That workflow is not touched by this PR, and it passes on my other branch. It fails here anyway. So the failure is not about the content of any one file.

The actual mechanism: GitHub refuses to start workflow runs for a pull request from a fork when that pull request modifies anything under .github/workflows/. The fork's GITHUB_TOKEN has no workflows permission, so the run set cannot be constructed and every workflow in it fails at startup. It is a security control, not a bug, and it applies to the whole run set rather than just the edited file.

Corroborating evidence from this repo: I checked the last 60 merged PRs. Every merged PR that touched .github/workflows/ came from a branch in garrytan/gstack itself. Zero fork PRs have ever modified a workflow file here. There is no counterexample, because there cannot be one.

So this PR is in the somewhat funny position of being blocked by a stricter cousin of the exact problem it is trying to fix.

What I did verify locally

  • actionlint v1.7.11 against the modified file: same 2 pre-existing runner-label warnings for ubicloud-standard-8 as baseline main, no new findings.
  • YAML parses cleanly (js-yaml).
  • Job graph and every needs: edge unchanged from main.

What a maintainer needs to do

Because of the restriction above, this change can only be exercised from a branch in the base repo:

git fetch https://github.com/andrey-esipov/astack.git ci/fork-pr-eval-skip
git push origin FETCH_HEAD:refs/heads/ci/fork-pr-eval-skip

Then open a PR from that branch, or just cherry-pick the single commit. It is one file and roughly ten lines.

What the change does

On a fork PR:

  • build-image still runs and still builds Dockerfile.ci, so Dockerfile changes stay validated. Only the push: to ghcr is turned off, since a fork token only has packages: read.
  • evals and report skip.

On same-repo PRs, pushes, and workflow_dispatch, nothing changes. Full coverage.

Why it is worth doing

Right now the outcome for a fork PR is decided by Docker layer cache state, not by the code:

  • Warm cachebuild-image finds the tag, skips the push, passes → evals runs → every API-calling shard fails at SDK auth because fork PRs get no secrets → 8 red checks.
  • Cold cachebuild-image tries to push, is denied, fails → evals is skipped → the PR looks clean.

Same commit, opposite verdict, depending on whether someone else happened to warm the cache first. #2264 merged with build-image failing and evals: skipping. #2323 got 8 red checks for the same underlying reason. Neither result says anything about the code.

This just makes the skip explicit instead of accidental.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant