fix(ci): stop fork PRs failing on write-scoped checks - #558
Merged
Conversation
Contributor
|
| Filename | Overview |
|---|---|
| .github/workflows/release-preview-comment.yml | Adds a privileged artifact consumer that independently resolves the triggering PR and serializes marker-comment updates to prevent cross-PR and stale-run writes. |
| .github/workflows/release-preview.yml | Converts release preview generation into a read-only producer that stages markdown and uploads it for the privileged consumer. |
| .github/workflows/ci-required-status.yml | Adds a base-context workflow-run consumer that publishes the required commit status for completed fork CI runs. |
| .github/workflows/ci.yml | Skips the impossible inline status write for fork pull requests while preserving it for trusted event contexts. |
| .github/workflows/pr-title.yml | Removes the WIP mode and status permission that caused otherwise valid fork PR titles to fail on a write request. |
Sequence Diagram
sequenceDiagram
participant PR as Fork PR
participant Preview as Release Preview workflow
participant Artifact as Preview artifact
participant Comment as Privileged comment workflow
participant CI as CI workflow
participant Status as Required-status workflow
PR->>Preview: pull_request event
Preview->>Preview: Compute preview with read-only token
Preview->>Artifact: Upload preview markdown
Artifact->>Comment: workflow_run completion
Comment->>Comment: Resolve PR from run metadata
Comment->>PR: Create or update preview comment
PR->>CI: pull_request event
CI->>CI: Run required jobs and CI Summary
CI->>Status: workflow_run completion
Status->>Status: Read CI Summary conclusion
Status->>PR: Publish CI / Required status
Reviews (7): Last reviewed commit: "fix(ci): publish CI / Required for fork ..." | Re-trigger Greptile
goosewobbler
added a commit
that referenced
this pull request
Aug 1, 2026
Greptile review on #558. The consumer read the target PR number out of the downloaded artifact, which is produced by a workflow definition the fork controls — so a contributor could upload someone else's PR number and aim the writable token at that PR, creating or overwriting its release-preview comment with arbitrary markdown. Resolve the PR from the triggering run's head SHA instead, via listPullRequestsAssociatedWithCommit, and drop pr-number from the artifact entirely so there is nothing to forge. Only the body still comes from the artifact, and it can now only land on the PR that produced it. Requiring exactly one open PR whose head is still that SHA also fixes the second finding: a superseded run no longer matches, so it cannot overwrite a newer preview. A per-branch concurrency group keeps two consumers for the same PR from interleaving their writes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT
goosewobbler
added a commit
that referenced
this pull request
Aug 1, 2026
Greptile follow-up on #558. label/unlabel events reuse the head SHA, so the SHA check alone treats an older and a newer preview run as equally current. Cancelling the superseded consumer does not recall an update it has already dispatched, so the older write could still land last. Stamp each comment with the producing run's `run_number` — monotonic per workflow, so it orders runs that share a SHA — and refuse to overwrite a comment a newer run already wrote. Comments predating the stamp carry no tag and are still updated normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT
goosewobbler
added a commit
that referenced
this pull request
Aug 1, 2026
Greptile follow-up on #558. The run-number stamp is a read-check-write, and `cancel-in-progress: true` left two consumers for the same branch live at once — both could read the old stamp and race, and cancelling the loser does not recall an update it had already dispatched. Queue them instead. The concurrency group then gives the read-check-write mutual exclusion, and the stamp decides the winner deterministically. GitHub keeps only the newest pending run per group, so a burst of label events collapses to first plus last rather than building a backlog. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT
goosewobbler
added a commit
that referenced
this pull request
Aug 1, 2026
Greptile follow-up on #558. Matching only the head SHA would also select an unrelated PR that happens to sit on the same commit, so the resolved PR was not provably the one that produced the run. Require the source repo and branch to match as well — all three come from the triggering run's GitHub-supplied metadata, none from the artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT
This was referenced Aug 1, 2026
A `pull_request` run from a fork gets a read-only GITHUB_TOKEN regardless of the `permissions:` block, so both of these 403'd on every external contributor PR while passing on same-repo branches. Validate PR Title: the action only POSTs a commit status when `wip: true` is set, and that request throws before the title verdict is reported — so the job failed even when the title was valid conventional-commits. The input exists for repos without draft PRs; this one is public, so drafts cover the same need. Dropping it removes the only write this workflow made, so `statuses: write` goes with it. Release Preview: posting the comment genuinely needs write, and the job checks out and runs code, so `pull_request_target` would mean running untrusted code with a writable token. Split it instead — the preview job now runs releasekit with `preview-dry-run` and uploads the markdown as an artifact, and a new `workflow_run` workflow posts it from the base-repo context. Same marker (`<!-- releasekit-preview -->`), so existing preview comments update in place rather than duplicating. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT
Greptile review on #558. The consumer read the target PR number out of the downloaded artifact, which is produced by a workflow definition the fork controls — so a contributor could upload someone else's PR number and aim the writable token at that PR, creating or overwriting its release-preview comment with arbitrary markdown. Resolve the PR from the triggering run's head SHA instead, via listPullRequestsAssociatedWithCommit, and drop pr-number from the artifact entirely so there is nothing to forge. Only the body still comes from the artifact, and it can now only land on the PR that produced it. Requiring exactly one open PR whose head is still that SHA also fixes the second finding: a superseded run no longer matches, so it cannot overwrite a newer preview. A per-branch concurrency group keeps two consumers for the same PR from interleaving their writes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT
Greptile follow-up on #558. label/unlabel events reuse the head SHA, so the SHA check alone treats an older and a newer preview run as equally current. Cancelling the superseded consumer does not recall an update it has already dispatched, so the older write could still land last. Stamp each comment with the producing run's `run_number` — monotonic per workflow, so it orders runs that share a SHA — and refuse to overwrite a comment a newer run already wrote. Comments predating the stamp carry no tag and are still updated normally. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT
Greptile follow-up on #558. The run-number stamp is a read-check-write, and `cancel-in-progress: true` left two consumers for the same branch live at once — both could read the old stamp and race, and cancelling the loser does not recall an update it had already dispatched. Queue them instead. The concurrency group then gives the read-check-write mutual exclusion, and the stamp decides the winner deterministically. GitHub keeps only the newest pending run per group, so a burst of label events collapses to first plus last rather than building a backlog. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT
Greptile follow-up on #558. Matching only the head SHA would also select an unrelated PR that happens to sit on the same commit, so the resolved PR was not provably the one that produced the run. Require the source repo and branch to match as well — all three come from the triggering run's GitHub-supplied metadata, none from the artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT
The `ci-required` ruleset requires the `CI / Required` status context, but on a fork PR the publish call in ci-status 403s on the read-only GITHUB_TOKEN. The step failure took the whole job down and the context was never published, so the ruleset could never be satisfied — external contributions were unmergeable, not merely red. Skip the publish in ci.yml when the head repo is a fork (it cannot succeed there), and add a workflow_run consumer that publishes the context from the base repo instead. The verdict is read from the conclusion GitHub recorded for the `CI Summary` job rather than from anything the fork produces, so ci-status.needs stays the single source of truth for which jobs are required — quarantined jobs remain excluded. An absent or non-success summary publishes failure rather than nothing, so the context is never left pending with no way to resolve it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT
goosewobbler
force-pushed
the
fix/fork-pr-ci-permissions
branch
from
August 1, 2026 23:03
8eb9d65 to
02d24d9
Compare
goosewobbler
added a commit
that referenced
this pull request
Aug 2, 2026
…rrences New transient class F9 (RN Android new-arch native-find). Broaden F2 (Electron E2E teardown/hang) to cover Linux — same signature (`Timeout reached` after a healthy session through `deleteSession()`) on `E2E - Electron [Linux] - builder`. Occurrences: #558 run 30722562323 (F9/F1/F4/F3); #557 run 30722561443 (F2 Electron Linux builder + F1 Tauri macOS standalone + F4 Flutter iOS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V
goosewobbler
added a commit
that referenced
this pull request
Aug 2, 2026
…rrences New transient class F9 (RN Android new-arch native-find) — recurs across job retries (#558 run 30722562323 attempts 1-2 both failed; green on main). Broaden F2 (Electron E2E teardown/hang) to cover Linux — same signature (`Timeout reached` after a healthy session through `deleteSession()`) on `E2E - Electron [Linux] - builder`. Occurrences: #558 run 30722562323 (F9 att.1-2 / F1 / F4 / F3); #557 run 30722561443 (F2 Electron Linux builder + F1 Tauri macOS standalone + F4 Flutter iOS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V
goosewobbler
added a commit
that referenced
this pull request
Aug 2, 2026
…rrences New transient class F9 (RN Android new-arch native-find) — recurs across job retries (#558 attempts 1-2). Broaden F2 (Electron E2E teardown/hang) to Linux — same signature on `E2E - Electron [Linux] - builder`. F4 (Flutter iOS) also seen recurring across job retries (#557 attempts 1-2). Occurrences: #558 run 30722562323 (F9 att.1-2 / F1 / F4 / F3); #557 run 30722561443 (F2 Electron Linux builder + F1 Tauri macOS standalone + F4 Flutter iOS att.1-2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V
goosewobbler
added a commit
that referenced
this pull request
Aug 2, 2026
…ccurrences New transient classes: F9 (RN Android new-arch native-find), F10 (unit/integration suite hang, SIGINT/130 — green on main), F11 (RN iOS E2E intermittent spec fail). Broaden F2 (Electron E2E teardown/hang) to Linux. Occurrences: #558 run 30722562323 (F9 att.1-2 / F1 / F4 / F3); #557 run 30722561443 (F2 Electron Linux + F1 Tauri macOS + F4 Flutter iOS att.1-2); #519 run 30723856572 (F9 RN Android + F11 RN iOS old-arch + F10 Unit macOS-Intel + F3 Dioxus Fedora). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V
goosewobbler
added a commit
that referenced
this pull request
Aug 2, 2026
…ccurrences New transient classes: F9 (RN Android new-arch native-find), F10 (unit/integration suite hang, SIGINT/130 — green on main), F11 (RN iOS E2E intermittent spec fail). Broaden F2 (Electron E2E teardown/hang) to Linux. F1/F4/F9 all seen surviving across job retries. Occurrences: #558 run 30722562323 (F9 att.1-2 / F1 / F4 / F3); #557 run 30722561443 (F2 Electron Linux + F1 Tauri macOS standalone att.1-3 + F4 Flutter iOS att.1-2); #519 run 30723856572 (F9 RN Android + F11 RN iOS old-arch + F10 Unit macOS-Intel + F3 Dioxus). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V
goosewobbler
added a commit
that referenced
this pull request
Aug 2, 2026
…ccurrences New transient classes: F9 (RN Android new-arch native-find), F10 (unit/integration suite hang, SIGINT/130 — green on main), F11 (RN iOS E2E intermittent spec fail). Broaden F2 (Electron E2E teardown/hang) to Linux. F1/F4/F9 seen surviving retries; F1 on #557 took 5 attempts to clear (~1-in-5 clear rate un-pumped). Occurrences: #558 run 30722562323 (F9 att.1-2 / F1 / F4 / F3); #557 run 30722561443 (F2 Electron Linux + F1 Tauri macOS standalone att.1-4 cleared att.5 + F4 Flutter att.1-2); #519 run 30723856572 (F9 RN Android + F11 RN iOS old-arch + F10 Unit macOS-Intel + F3 Dioxus). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V
goosewobbler
added a commit
that referenced
this pull request
Aug 2, 2026
#564) * docs(ci): add F9/F10/F11 + broaden F2 to Linux + log #557/#558/#519 occurrences New transient classes: F9 (RN Android new-arch native-find), F10 (unit/integration suite hang, SIGINT/130 — green on main), F11 (RN iOS E2E intermittent spec fail). Broaden F2 (Electron E2E teardown/hang) to Linux. F1/F4/F9 seen surviving retries; F1 on #557 took 5 attempts to clear (~1-in-5 clear rate un-pumped). Occurrences: #558 run 30722562323 (F9 att.1-2 / F1 / F4 / F3); #557 run 30722561443 (F2 Electron Linux + F1 Tauri macOS standalone att.1-4 cleared att.5 + F4 Flutter att.1-2); #519 run 30723856572 (F9 RN Android + F11 RN iOS old-arch + F10 Unit macOS-Intel + F3 Dioxus). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V * docs(flake-log): log run 30730874052 (#528) — F1/F2/F4/F9 Dependabot setup-node 6→7 bump drew all four top app-flake classes in one full-matrix run: F1 (Tauri macOS standalone), F2 (Electron Linux script teardown), F4 (Flutter iOS exit-1), F9 (RN Android new-arch native find). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V * docs(flake-log): annotate run 30730874052 retry outcomes Attempt-2 retry: F1 (Tauri macOS) and F2 (Electron Linux) cleared, while F4 (Flutter iOS) and F9 (RN Android new-arch) recurred on both attempts — the two stubborn-on-retry classes on this run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V * docs(flake-log): log #537 run 30750927964 — F1 ×2 (embedded, pre-#553) + F3 Fedora Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V * docs(flake-log): #537 run 30750927964 full retry history — F1 standalone recurred att.1–4, cleared att.5 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKc4JHZKXfyqCiiU4C2b7V --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Validate PR TitleandRelease Previewfail withResource not accessible by integrationon every external contributor PR.Root cause: a
pull_requestrun from a forked repository gets a read-onlyGITHUB_TOKENregardless of what the workflow'spermissions:block requests. That's a GitHub platform rule, not a misconfiguration — so any write call 403s.Evidence:
1. Validate PR Title —
create-a-commit-status403Traced to the action's source: the
POST /repos/:owner/:repo/statuses/:shacall sits insideif (wip), so it only happens because we passwip: true. And it runs before the title verdict is evaluated:So the check is a 100% false negative for external contributors — #527's title (
fix(tauri): use CSS pixels for embedded window rect) is valid conventional-commits and still failed.The action's own comment says
wipis for "repositories which don't support draft pull requests" — private repos on the free plan. This repo is public, so draft PRs already cover that. Droppingwip: trueremoves the only write the workflow made, sostatuses: writegoes with it.2. Release Preview —
create-an-issue-comment403This one genuinely needs
pull-requests: write, and it doesactions/checkout+ runs releasekit over the tree. Switching it topull_request_targetwould either preview the base branch (useless) or run untrusted PR code with a writable token and secrets — the classic pwn-request. So it's split into the pattern GitHub documents for exactly this:release-preview.yml(pull_request,contents: read) — runs releasekit withpreview-dry-run: true, which returns the markdown via the existingpreview-markdownoutput instead of posting, then uploads it as an artifact.release-preview-comment.yml(workflow_run,pull-requests: write) — downloads the artifact from the triggering run and posts it. Runs in the base-repo context, never checks out PR code.No releasekit changes needed —
preview-dry-runandpreview-markdownalready exist. The comment reuses releasekit's own<!-- releasekit-preview -->marker, so existing preview comments update in place rather than duplicating, and both fork and same-repo PRs now take one consistent path.The consumer treats the artifact as untrusted, because a fork controls the workflow definition that produces it. The target PR is resolved from the triggering run's own metadata — head SHA and source repo and branch, all GitHub-supplied — so the body can only ever land on the PR that produced it. Consumers are serialised per branch and stamped with the producing run number, so a superseded run cannot overwrite a newer preview.
3.
CI / Required—create-a-commit-status403 (the blocking one)The
ci-requiredruleset requires theCI / Requiredstatus context:That context is published by
ci-status's final step viagh api .../statuses/$SHA. On a fork PR that call 403s, which fails the step, which fails the job — so the context is never published at all. The ruleset then sits unsatisfied with nothing capable of resolving it, which means external contributions are not merely red, they are unmergeable.Same split as above:
ci.yml— the publish step is skipped when the head repo is a fork, since it cannot succeed there.head.repo.forkis null on push/workflow_dispatch, so those keep publishing inline exactly as before.ci-required-status.yml(workflow_run,statuses: write) — publishes the context for fork runs from the base-repo context.The verdict is not taken from anything the fork produces: it reads the conclusion GitHub recorded for the
CI Summaryjob. That job'sneedslist already encodes the curated required-job set (quarantined legs likee2e-flutter-ios-macosare deliberately excluded there), soci-status.needsremains the single source of truth and no job list is duplicated. An absent or non-success summary publishesfailurerather than nothing, so the context is never left pending with no way to resolve it.Type of Change
Testing
actionlint .github/workflows/*.yml— clean (this is what thelintjob runs)actions/download-artifact@v8supportsrun-id+github-tokenfor cross-run download, and matched the action versions already used in this repo (upload-artifact@v7,download-artifact@v8,github-script@v9)Three things that can't be demonstrated on this PR
workflow_runworkflows only ever run from the copy on the default branch. The comment half therefore does nothing until this merges — this PR's own checks can't exercise it. First real validation is the next PR opened after merge.pull_requestworkflows run from the PR's merge ref, so already-open fork PRs (fix(tauri): use CSS pixels for embedded window rect #527, feat(tauri): support child webview handles #532) keep using the oldpr-title.ymlandci.ymluntil their base is updated. Re-running after merge, or their next push, picks up the fix.ci-required-status.yml— being aworkflow_runworkflow it is inert until merged, so the first fork PR to run CI after merge is the real test of theCI / Requiredpath.🤖 Generated with Claude Code
https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT