Skip to content

PR previews: Resolve fork pull requests when publishing - #528

Merged
mmtr merged 2 commits into
trunkfrom
fix/pr-preview-resolve-fork-prs
Aug 7, 2026
Merged

PR previews: Resolve fork pull requests when publishing#528
mmtr merged 2 commits into
trunkfrom
fix/pr-preview-resolve-fork-prs

Conversation

@mmtr

@mmtr mmtr commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Proposed Changes

pr-preview-publish.yml now resolves the pull request by head repository and branch instead of by commit. The lookup moves from listPullRequestsAssociatedWithCommit to pulls.list with a head=owner:branch filter.

Owner, branch and head SHA are then re-checked locally against the workflow_run payload, so the resolved PR has to match on all three before the artifact is exposed or the comment posted.

The query uses state: 'all' rather than open PRs only, so a PR that merges between the build finishing and this run starting still publishes, as it did before. When a branch matches more than one PR, the open one wins.

Why are these changes being made?

Because fork pull requests never got a Playground preview. listPullRequestsAssociatedWithCommit does not resolve commits that live in a fork, so the resolve step failed and every later step was skipped:

No pull request found with head SHA 00dba781c2747209cf17be014ab65a1364cc4e49; refusing to publish a preview.

The endpoint returns nothing for fork heads and one result for same-repo heads:

PR Fork Results
#513 (nuriapenya) yes 0
#443 (CookieDarb) yes 0
#525 (WordPress) no 1

Neither #513 nor #443 has ever received a preview comment. This is not a regression from the recent action bumps: the same error hit a run on 6 August, and in the failing run above the actions/checkout step succeeded and only the resolve step failed.

The workflow already knew about half of this. Its comment notes that workflow_run.pull_requests is empty for fork PRs and reaches for the commits API as the workaround, but that API has the same blind spot.

Why ownership is checked locally

head_repository and head_branch are set by GitHub on the workflow_run payload, exactly like head_sha, and PR code cannot spoof them. The owner segment of the filter is what stops a fork from matching someone else's PR, and it is enforced:

head=WordPress:app-icons-one-family              -> 0
head=nuriapenya:fix/pr-preview-resolve-fork-prs  -> 0

But the filter is server-side and fails open. A value GitHub does not parse is ignored rather than rejected, and the call returns everything:

head=app-icons-one-family   -> 100
head=totally-bogus-value    -> 100

The !headOwner || !headBranch guard makes a malformed value impossible today, since git refs cannot contain : and logins are alphanumeric, so this is not a live bug. It is still the whole ownership guarantee resting on remote filter behaviour that nothing local would notice changing. Re-checking head.repo.owner.login, head.ref and head.sha against the payload turns it into a local invariant for one line. head.repo is nullable when the head fork has been deleted, hence the optional chaining.

The comment block also drops its reference to a pr-meta artifact. No such artifact exists: pr-preview-build.yml uploads only built-plugin, and its own header says it deliberately emits no PR metadata. The reasoning it was making is kept, without naming a file that is not produced.

Testing Instructions

The workflow_run trigger always runs the copy of this file that is on the default branch, so the end-to-end path can only be exercised after merge. Steps 1 to 4 can be run now.

1. The new lookup resolves both fork and same-repo PRs

Run each of these and make sure a PR number and head SHA come back:

gh api "repos/WordPress/openstation/pulls?state=all&per_page=100&head=nuriapenya:app-icons-one-family" -q '.[] | "#" + (.number|tostring) + "  " + .head.sha'
gh api "repos/WordPress/openstation/pulls?state=all&per_page=100&head=CookieDarb:fix/redundant-add-button" -q '.[] | "#" + (.number|tostring) + "  " + .head.sha'

Then pick any open same-repo PR and run the same call with head=WordPress:<its branch>. Make sure it also returns one row. That is the regression check: same-repo PRs must keep working.

2. The old lookup is the thing that was broken

gh api repos/WordPress/openstation/commits/00dba781c2747209cf17be014ab65a1364cc4e49/pulls -q 'length'

Make sure it prints 0, while step 1 resolved the same branch to #513.

3. The local check survives a fail-open response

Fetch the worst case, where the filter is ignored and every PR comes back, then run the resolution logic against it:

gh api "repos/WordPress/openstation/pulls?state=all&per_page=100&head=totally-bogus-value" > /tmp/allprs.json
node -e "const prs=require('/tmp/allprs.json');const r=(o,b,s)=>{const m=prs.filter(p=>p.head.sha===s&&p.head.repo?.owner?.login===o&&p.head.ref===b);const p=m.find(x=>x.state==='open')??m[0];return p?'#'+p.number:'REFUSED'};const t=prs.find(p=>p.number===513);console.log('size',prs.length);console.log('correct   ->',r(t.head.repo.owner.login,t.head.ref,t.head.sha));console.log('bad owner ->',r('attacker',t.head.ref,t.head.sha));console.log('bad branch->',r(t.head.repo.owner.login,'other',t.head.sha));console.log('bad sha   ->',r(t.head.repo.owner.login,t.head.ref,'0'.repeat(40)))"

Make sure the response size is 100, the correct triple resolves to #513, and all three tampered variants print REFUSED.

4. The file still parses

node -e "const y=require('js-yaml'),f=require('fs');const d=y.load(f.readFileSync('.github/workflows/pr-preview-publish.yml','utf8'));const s=d.jobs.publish.steps.find(x=>x.name&&x.name.startsWith('Resolve PR'));f.writeFileSync('/tmp/s.js','(async()=>{'+s.with.script+'})()');console.log('yaml ok')" && node --check /tmp/s.js && echo "js ok"

5. After merge, a fork PR publishes

  1. Push any commit to App icons: one family for the four built-ins #513 or Fix(window): refine page-title-action button redundancy and layout positioning #443 (a merge of trunk into the branch is enough).
  2. Wait for PR Preview Build to finish, then open the PR Preview Publish run it triggers.
  3. Make sure the run is green and no step is skipped.
  4. Make sure a Playground preview comment appears on the fork PR, and that the button opens a Playground with the plugin from that PR installed.

6. After merge, a same-repo PR still publishes

  1. Push a commit to any open PR on a WordPress/openstation branch.
  2. Make sure its preview comment is posted or updated as before.

7. After merge, a stale run still refuses

  1. Push twice to a PR in quick succession so the first publish run resolves against a superseded head SHA.
  2. Make sure the earlier run fails with No pull request for <owner>:<branch> at head SHA ... and publishes nothing, and that the run for the newer SHA succeeds.

Automated

npm run build

Make sure git status is clean afterwards. No source files change here, so the build is only a sync check.

Open WordPress Playground Preview

Look the pull request up by head repository and branch instead of by
commit. listPullRequestsAssociatedWithCommit does not resolve commits
that live in a fork, so every fork PR failed the resolve step and never
got a Playground preview.

The head SHA still has to match before anything is published, and
head_repository and head_branch come from the same trusted workflow_run
payload as head_sha, so the trust model is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mmtr mmtr self-assigned this Aug 7, 2026
The head filter is server-side and fails open: a value GitHub does not
recognise is ignored rather than rejected, and the call returns every PR.
Re-check owner, branch and SHA against the workflow_run payload so the
ownership guarantee holds locally.

Also drops the comment's reference to a pr-meta artifact, which
pr-preview-build.yml does not produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mmtr
mmtr merged commit 4fa94c0 into trunk Aug 7, 2026
5 checks passed
@mmtr
mmtr deleted the fix/pr-preview-resolve-fork-prs branch August 7, 2026 14:19
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