Skip to content

fix(ci): stop fork PRs failing on write-scoped checks - #558

Merged
goosewobbler merged 6 commits into
mainfrom
fix/fork-pr-ci-permissions
Aug 2, 2026
Merged

fix(ci): stop fork PRs failing on write-scoped checks#558
goosewobbler merged 6 commits into
mainfrom
fix/fork-pr-ci-permissions

Conversation

@goosewobbler

@goosewobbler goosewobbler commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Description

Validate PR Title and Release Preview fail with Resource not accessible by integration on every external contributor PR.

Root cause: a pull_request run from a forked repository gets a read-only GITHUB_TOKEN regardless of what the workflow's permissions: block requests. That's a GitHub platform rule, not a misconfiguration — so any write call 403s.

Evidence:

PR Fork? Validate PR Title Release Preview
#556 no pass pass
#557 no pass pass
#527 yes fail fail

1. Validate PR Title — create-a-commit-status 403

Traced to the action's source: the POST /repos/:owner/:repo/statuses/:sha call sits inside if (wip), so it only happens because we pass wip: true. And it runs before the title verdict is evaluated:

if (wip) {
  await client.request('POST /repos/:owner/:repo/statuses/:sha', {});  // 403 on forks
}
if (!isWip && validationError) {
  throw validationError;
}

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 wip is 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. Dropping wip: true removes the only write the workflow made, so statuses: write goes with it.

2. Release Preview — create-an-issue-comment 403

This one genuinely needs pull-requests: write, and it does actions/checkout + runs releasekit over the tree. Switching it to pull_request_target would 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 with preview-dry-run: true, which returns the markdown via the existing preview-markdown output 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-run and preview-markdown already 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 / Requiredcreate-a-commit-status 403 (the blocking one)

The ci-required ruleset requires the CI / Required status context:

$ gh api /repos/webdriverio/desktop-mobile/rulesets/18026650 --jq '.rules[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context'
CI / Required

That context is published by ci-status's final step via gh 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.fork is 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 Summary job. That job's needs list already encodes the curated required-job set (quarantined legs like e2e-flutter-ios-macos are deliberately excluded there), so ci-status.needs remains the single source of truth and no job list is duplicated. An absent or non-success summary publishes failure rather than nothing, so the context is never left pending with no way to resolve it.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Internal/tooling change (build scripts, CI, etc.)

Testing

  • actionlint .github/workflows/*.yml — clean (this is what the lint job runs)
  • All three files parse
  • Verified actions/download-artifact@v8 supports run-id + github-token for 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

  1. workflow_run workflows 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.
  2. pull_request workflows 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 old pr-title.yml and ci.yml until their base is updated. Re-running after merge, or their next push, picks up the fix.
  3. Same for ci-required-status.yml — being a workflow_run workflow it is inert until merged, so the first fork PR to run CI after merge is the real test of the CI / Required path.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CsgeivZCqduTQmWK55zqiT

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR restructures write-scoped checks so fork pull requests can complete without receiving a writable token.

  • Removes the unnecessary commit-status write from PR-title validation.
  • Produces release-preview markdown in the read-only PR workflow and posts it from a privileged workflow_run consumer.
  • Publishes the required CI status for fork runs from a base-repository workflow using the recorded CI summary result.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported privileged-target and stale-update issues are addressed by authoritative target resolution, ambiguity refusal, branch-scoped serialization, and monotonic run stamps.

Important Files Changed

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
Loading

Reviews (7): Last reviewed commit: "fix(ci): publish CI / Required for fork ..." | Re-trigger Greptile

Comment thread .github/workflows/release-preview-comment.yml Outdated
Comment thread .github/workflows/release-preview-comment.yml
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
Comment thread .github/workflows/release-preview-comment.yml Outdated
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
goosewobbler and others added 6 commits August 2, 2026 00:03
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
goosewobbler force-pushed the fix/fork-pr-ci-permissions branch from 8eb9d65 to 02d24d9 Compare August 1, 2026 23:03
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
goosewobbler merged commit b378d2f into main Aug 2, 2026
363 of 370 checks passed
@goosewobbler
goosewobbler deleted the fix/fork-pr-ci-permissions branch August 2, 2026 01:57
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>
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