Skip to content

Add a script for adopting fork pull requests - #97252

Merged
eps1lon merged 4 commits into
canaryfrom
sebbie/fork-pr-adoption-command-9c6eb4
Aug 13, 2026
Merged

Add a script for adopting fork pull requests#97252
eps1lon merged 4 commits into
canaryfrom
sebbie/fork-pr-adoption-command-9c6eb4

Conversation

@eps1lon

@eps1lon eps1lon commented Aug 12, 2026

Copy link
Copy Markdown
Member

Fork PRs run without repository secrets, so deploy tests never run on external contributions. The workaround is to re-push the contributor's commits to a branch in vercel/next.js and open a replacement PR from there, which is fiddly by hand and carries a real security risk. This adds a pr-adopt PNPM script to encode the procedure.

pnpm pr-adopt <pr-number>            # adopt
node pr-adopt <pr-number> --dry-run  # report without pushing

We copy over the title and body as-is and prepend a Adopts #N. Closes #N. line so that the underlying PR automatically closes and we create the proper backlinks.

Commits are signed

The description is copied rather than rewritten for two reasons: it is the contributor's own text, and any Fixes #123 inside it has to survive, since the original PR closes unmerged and so never fires its own.

The confirmation gate

Adoption runs the contributor's code on a branch where CI has repository secrets, which is exactly what a fork PR is denied. Anything executing during install, build, or test can read and exfiltrate them, so before touching git the script prints the PR link, status, author handle and association, diff size, and every file touched. Confirming requires retyping the author's handle, and a non-interactive stdin is refused rather than silently proceeding.

Two details worth a reviewer's attention:

  • The file list is deliberately unranked. An earlier revision flagged "high-risk" paths (.github/, package.json, lockfiles). That was removed: a payload can sit in any fixture or source file, and printing "no high-risk paths touched" manufactures false assurance.
  • The list comes from the paginated REST endpoint. gh pr view --json files silently caps at 100 entries. Upgrade React from cbb046ab-20260731 to 7dfc7ccd-20260803 #96550 has 106 changed files and returns 100, which would present a truncated list as the complete surface. If the count still falls short of changedFiles (GitHub stops at 3000), the shortfall is reported rather than hidden.

Draft and closed PRs are both adoptable, since a contributor may still be iterating or may have abandoned an unreviewed change; the status is surfaced rather than enforced. Merged PRs are refused because their commits are already in canary.

Test plan

$ pnpm pr-adopt 96525
Adopting #96525 - [testmode] Fix infinite recursion in testmode passthrough fetch
  Link      https://github.com/vercel/next.js/pull/96525
  Status    OPEN
  Author    @lazerg (CONTRIBUTOR)
  Source    lazerg:fix/issue-96521-testmode-passthrough-recursion
  Diff      2 files, +19 -2, across 1 commit(s)
  Push to   origin (vercel/next.js) as adopt/96525
  Base      canary

  Files touched (2):
    packages/next/src/experimental/testmode/fetch.ts
    test/e2e/testmode/testmode.test.ts

  This grants the contributor code access to secrets.

  A fork PR is denied repository secrets because its code is not trusted.
  Adopting runs that same code on a branch inside vercel/next.js, where CI has
  those secrets. Anything in this diff that executes during install,
  build, or test can read and exfiltrate them. The dangerous line does
  not have to be in the change the PR claims to make, and no file in the
  list above is safe by virtue of where it sits.

  Read the full diff:  gh pr diff 96525 --repo vercel/next.js

  Type the author's handle (lazerg) to confirm you have read
  the full diff and vouch for this code: lazerg
Opening the replacement pull request

Adopted.
  Original  https://github.com/vercel/next.js/pull/96525
  Adopted   https://github.com/vercel/next.js/pull/97257

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

🔴 1 regression, 2 improvements

Metric Canary PR Change Trend
Webpack Warm (First Request) 3.281s 3.452s 🔴 +171ms (+5%) ▂▁▁▂▁
Webpack Build Time 24.548s 23.922s 🟢 626ms (-3%) ▁▁▁▁▁
Webpack Build Time (cached) 24.626s 24.076s 🟢 550ms (-2%) ▂▁▁▂▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 761ms 762ms ▁▂▂▂▂
Cold (Ready in log) 759ms 767ms ▁▁▁▁▁
Cold (First Request) 1.263s 1.267s ▁▁▂▁▁
Warm (Listen) 762ms 761ms ▁▁▁▁▁
Warm (Ready in log) 764ms 764ms ▁▁▁▁▁
Warm (First Request) 1.241s 1.235s ▁▁▂▁▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 812ms 813ms ▂▂▂▂▂
Cold (Ready in log) 777ms 783ms ▂▁▁▁▁
Cold (First Request) 3.297s 3.394s ▂▁▁▂▁
Warm (Listen) 812ms 813ms ▁▁▁▁▁
Warm (Ready in log) 776ms 798ms ▂▁▁▁▁
Warm (First Request) 3.281s 3.452s 🔴 +171ms (+5%) ▂▁▁▂▁

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 5.578s 5.696s ▁▁▂▁▁
Cached Build 2.839s 2.836s ▁▁▃▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 24.548s 23.922s 🟢 626ms (-3%) ▁▁▁▁▁
Cached Build 24.626s 24.076s 🟢 550ms (-2%) ▂▁▁▂▁
node_modules Size 554 MB 554 MB ▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles
Canary PR Change
03lzpt71kg1qg.js gzip 47 kB N/A -
03o8j4gdcreqe.js gzip 170 B N/A -
0405hwyamng46.js gzip 157 B N/A -
050icza-xjz0i.js gzip 5.73 kB N/A -
07jdby0ue616s.js gzip 450 B N/A -
0bjdc8muo74n5.js gzip 8.71 kB N/A -
0cz1d0mv5g_q7.js gzip 39.4 kB 39.4 kB
0rci1f3or1a19.js gzip 13.3 kB N/A -
1_2x714--ii1i.js gzip 8.76 kB N/A -
1-3y752pkth5-.js gzip 10 kB N/A -
1elt1qium-r2m.css gzip 115 B 115 B
1ewkwfyqom2y8.js gzip 158 B N/A -
1gyf22uwo685a.js gzip 158 B N/A -
1hxhm60beg9ej.js gzip 71.7 kB N/A -
1k6dd7su4cfei.js gzip 3.57 kB N/A -
1tf1phijqlx9j.js gzip 220 B 220 B
1uzabyd1120a1.js gzip 8.71 kB N/A -
1vvaqv96pfhdm.js gzip 156 B N/A -
2-ufv8lc-g7gg.js gzip 10.6 kB N/A -
21kmjy_10x14f.js gzip 8.81 kB N/A -
28dhc6t85q1_p.js gzip 8.78 kB N/A -
2f-ilvczue-tp.js gzip 9.46 kB N/A -
2f1u17u5c8iny.js gzip 8.79 kB N/A -
2fklnf1x-tk68.js gzip 7.53 kB N/A -
2ifum2q5l9pnn.js gzip 155 B N/A -
2ikltg_8iegxw.js gzip 10.3 kB N/A -
2vxi673cz1-t4.js gzip 8.79 kB N/A -
2w4laifzt1o9q.js gzip 157 B N/A -
31dg94q7iv1fe.js gzip 160 B N/A -
38-q43pzktqhs.js gzip 1.46 kB N/A -
3j981cx6o_t11.js gzip 155 B N/A -
3ki92ksbh68ff.js gzip 152 B N/A -
3l9pr36yz-v2o.js gzip 161 B N/A -
3mrwxzed0ylgv.js gzip 13.1 kB N/A -
3nbojhxiy1qv_.js gzip 13.7 kB N/A -
3qn872h2wdhw7.js gzip 157 B N/A -
3ueg8wcmwx2u-.js gzip 156 B N/A -
41u5s3oe2-erp.js gzip 2.29 kB N/A -
445s_9hf8o7ao.js gzip 8.76 kB N/A -
44i244dmnulb-.js gzip 65.6 kB N/A -
turbopack-0d..9ejy.js gzip 3.74 kB 3.74 kB
0-6bcj16ji2wf.js gzip N/A 10.6 kB -
056018sbl3xxg.js gzip N/A 156 B -
07ryk0jced-sc.js gzip N/A 8.78 kB -
0a9npihj78qly.js gzip N/A 157 B -
0jwho9fkrb_t3.js gzip N/A 2.29 kB -
0roh390ijzxa5.js gzip N/A 8.75 kB -
0rvbkygoup2y6.js gzip N/A 157 B -
0vex9w55ursqj.js gzip N/A 8.79 kB -
0z8h9xivq9k4i.js gzip N/A 156 B -
0zn9pf1bv9yel.js gzip N/A 156 B -
17oe55cu76cd7.js gzip N/A 450 B -
19om32e1m-0cq.js gzip N/A 160 B -
1din_zw2g87o2.js gzip N/A 168 B -
1drww5xikb-c-.js gzip N/A 9.46 kB -
1f2amrrz84mzg.js gzip N/A 151 B -
1g60xde_dv17t.js gzip N/A 8.79 kB -
1o20zijn7kx7u.js gzip N/A 155 B -
1uzv47btzam64.js gzip N/A 5.73 kB -
1vy7n7wxv_rh5.js gzip N/A 8.81 kB -
1x0gfa0ls84m6.js gzip N/A 65.6 kB -
2-0i7pl900-ou.js gzip N/A 8.71 kB -
2-kcbngm7ik7y.js gzip N/A 8.75 kB -
29p5-xa4jmdhg.js gzip N/A 13.7 kB -
2jhbuzgq122u-.js gzip N/A 7.53 kB -
2lpk5_hknut8q.js gzip N/A 13.1 kB -
2o4cibp3awtbw.js gzip N/A 3.56 kB -
2ugc69z0t0ypz.js gzip N/A 1.46 kB -
336mo8ag01xm6.js gzip N/A 156 B -
36dn8i-_3dnq5.js gzip N/A 10 kB -
3fljpmwcjxqhx.js gzip N/A 10.3 kB -
3gz44skqdlsrb.js gzip N/A 8.71 kB -
3ia5w9d9qqfil.js gzip N/A 71.7 kB -
3nk46g6u37ekv.js gzip N/A 47 kB -
3q5e1j1558-w2.js gzip N/A 155 B -
3y9tus7kb5su0.js gzip N/A 13.3 kB -
446udw0vqke67.js gzip N/A 157 B -
44zkt6f12xfx-.js gzip N/A 162 B -
Total 401 kB 401 kB ⚠️ +7 B

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 1.06 kB 1.06 kB
Total 1.06 kB 1.06 kB
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 878 B 878 B
Total 878 B 878 B
Build Cache
Canary PR Change
00000001.sst gzip 14.7 MB 15 MB 🔴 +322 kB (+2%)
00000002.sst gzip 14.3 MB 12.9 MB 🟢 1.45 MB (-10%)
00000003.sst gzip 13.6 MB 13.3 MB 🟢 300 kB (-2%)
00000004.sst gzip 12.7 MB 14 MB 🔴 +1.36 MB (+11%)
00000005.sst gzip 10.2 MB 10.2 MB
00000006.sst gzip 2.8 MB 2.8 MB
00000007.sst gzip 59 B 59 B
00000008.meta gzip 89 B 89 B
00000009.meta gzip 298 kB 298 kB
00000010.meta gzip 298 kB 298 kB
00000011.meta gzip 298 kB 298 kB
00000012.sst gzip 50.7 kB 52.6 kB 🔴 +1.91 kB (+4%)
00000013.sst gzip 1.85 MB 1.82 MB 🟢 22.6 kB (-1%)
00000014.sst gzip 59 B 59 B
00000015.meta gzip 116 B 116 B
00000016.meta gzip 326 kB 326 kB
00000017.meta gzip 405 kB 405 kB
00000018.sst gzip 51.6 kB 53.4 kB 🔴 +1.81 kB (+4%)
00000019.sst gzip 1.38 MB 1.38 MB 🟢 6.58 kB (0%)
00000020.sst gzip 59 B 59 B
00000021.meta gzip 116 B 116 B
00000022.meta gzip 326 kB 326 kB
00000023.meta gzip 372 kB 372 kB
00000024.sst gzip 51.6 kB 53.4 kB 🔴 +1.84 kB (+4%)
00000025.sst gzip 1.38 MB 1.38 MB 🟢 5.93 kB (0%)
00000026.sst gzip 59 B 59 B
00000027.meta gzip 116 B 116 B
00000028.meta gzip 326 kB 326 kB
00000029.meta gzip 372 kB 372 kB
00000030.sst gzip 50.9 kB 52.7 kB 🔴 +1.85 kB (+4%)
00000031.sst gzip 1.38 MB 1.38 MB 🟢 6 kB (0%)
00000032.sst gzip 59 B 59 B
00000033.meta gzip 116 B 116 B
00000034.meta gzip 326 kB 326 kB
00000035.meta gzip 372 kB 372 kB
00000036.sst gzip 51.6 kB 53.3 kB 🔴 +1.69 kB (+3%)
00000037.sst gzip 1.38 MB 1.38 MB 🟢 6.01 kB (0%)
00000038.sst gzip 59 B 59 B
00000039.meta gzip 116 B 116 B
00000040.meta gzip 326 kB 326 kB
00000041.meta gzip 372 kB 372 kB
CURRENT gzip 94 B 94 B
LOG gzip 667 B 676 B 🔴 +9 B (+1%)
Total 80.3 MB 80.2 MB ✅ -102 kB

📦 Webpack

Client

Main Bundles
Canary PR Change
3322-HASH.js gzip 66.1 kB N/A -
4191.HASH.js gzip 169 B N/A -
7920-HASH.js gzip 4.67 kB N/A -
9784-HASH.js gzip 5.63 kB N/A -
b1ad9f4c-HASH.js gzip 63.4 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 251 B 252 B
main-HASH.js gzip 40.1 kB 40.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
3577.HASH.js gzip N/A 168 B -
578-HASH.js gzip N/A 66.8 kB -
8590-HASH.js gzip N/A 5.61 kB -
9750-HASH.js gzip N/A 4.68 kB -
a8984546-HASH.js gzip N/A 63.4 kB -
Total 242 kB 242 kB ⚠️ +624 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 193 B
_error-HASH.js gzip 181 B 182 B
css-HASH.js gzip 334 B 331 B
dynamic-HASH.js gzip 1.81 kB 1.81 kB
edge-ssr-HASH.js gzip 255 B 253 B
head-HASH.js gzip 349 B 351 B
hooks-HASH.js gzip 382 B 384 B
image-HASH.js gzip 581 B 582 B
index-HASH.js gzip 260 B 259 B
link-HASH.js gzip 2.48 kB 2.48 kB
routerDirect..HASH.js gzip 317 B 318 B
script-HASH.js gzip 384 B 386 B
withRouter-HASH.js gzip 316 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.95 kB 7.96 kB ⚠️ +4 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 129 kB 129 kB
page.js gzip 294 kB 295 kB
Total 423 kB 423 kB ⚠️ +3 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 619 B 618 B
middleware-r..fest.js gzip 156 B 156 B
middleware.js gzip 45.9 kB 45.4 kB 🟢 534 B (-1%)
edge-runtime..pack.js gzip 842 B 842 B
Total 47.5 kB 47 kB ✅ -535 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 717 B 718 B
Total 717 B 718 B ⚠️ +1 B
Build Cache
Canary PR Change
0.pack gzip 4.81 MB 4.8 MB
index.pack gzip 122 kB 123 kB
index.pack.old gzip 122 kB 121 kB
Total 5.05 MB 5.05 MB ✅ -3.17 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 375 kB 375 kB
app-page-exp..prod.js gzip 207 kB 207 kB
app-page-tur...dev.js gzip 375 kB 375 kB
app-page-tur..prod.js gzip 207 kB 207 kB
app-page-tur...dev.js gzip 371 kB 371 kB
app-page-tur..prod.js gzip 205 kB 205 kB
app-page.run...dev.js gzip 371 kB 371 kB
app-page.run..prod.js gzip 205 kB 205 kB
app-route-ex...dev.js gzip 82.9 kB 82.9 kB
app-route-ex..prod.js gzip 56.1 kB 56.1 kB
app-route-tu...dev.js gzip 83 kB 83 kB
app-route-tu..prod.js gzip 56.1 kB 56.1 kB
app-route-tu...dev.js gzip 82.5 kB 82.5 kB
app-route-tu..prod.js gzip 55.9 kB 55.9 kB
app-route.ru...dev.js gzip 82.5 kB 82.5 kB
app-route.ru..prod.js gzip 55.8 kB 55.8 kB
dev-validati...dev.js gzip 134 kB 134 kB
dev-validati...dev.js gzip 134 kB 134 kB
dev-validati...dev.js gzip 132 kB 132 kB
dev-validati...dev.js gzip 132 kB 132 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 46.4 kB 46.4 kB
pages-api-tu..prod.js gzip 34.6 kB 34.6 kB
pages-api.ru...dev.js gzip 46.4 kB 46.4 kB
pages-api.ru..prod.js gzip 34.5 kB 34.5 kB
pages-turbo....dev.js gzip 55.2 kB 55.2 kB
pages-turbo...prod.js gzip 40.1 kB 40.1 kB
pages.runtim...dev.js gzip 55.1 kB 55.1 kB
pages.runtim..prod.js gzip 40.1 kB 40.1 kB
server.runti..prod.js gzip 67.1 kB 67.1 kB
use-cache-pr...dev.js gzip 72.6 kB 72.6 kB
use-cache-pr...dev.js gzip 72.6 kB 72.6 kB
use-cache-pr...dev.js gzip 70.8 kB 70.8 kB
use-cache-pr...dev.js gzip 70.8 kB 70.8 kB
Total 4.11 MB 4.11 MB
📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/a48007ce0446ead2f2f86974eb27d199d494abd0/next

Commit: a48007c

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: a48007c

Comment thread scripts/adopt-pr.js
@eps1lon
eps1lon marked this pull request as ready for review August 12, 2026 18:12
@eps1lon
eps1lon requested review from bgw and ztanner August 12, 2026 18:12
Comment thread scripts/adopt-pr.js
const remote = await detectUpstreamRemote()
const pr = await fetchPullRequest(prNumber)

await preflight(pr, branch, dryRun)

@ztanner ztanner Aug 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it'd be worth rejecting if headRefOid changes from the time the commands run to when the PR information is fetched, just in case the underlying ref changes and now the contributor is adopting a PR that changed underneath them which could have security implications. Probably should even confirm with the adopter the sha of what they are fetching before proceeding

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we push the original commit that was approved on now.

Comment thread scripts/adopt-pr.js Outdated
console.log(bold(`Checking out #${pr.number} as ${branch}`))
// Fetches refs/pull/<n>/head, so the fork does not need to be a remote. The
// commits are not rewritten: authorship has to reach the merge intact.
await runInherit('gh', [

@ztanner ztanner Aug 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gh pr checkout can execute contributor-added hooks locally (for example, .husky/post-checkout). I think adoption should run with hooks disabled for all Git/rebase subprocesses. It may also be worth using a throwaway repo so the untrusted checkout and any failed rebase state never touch the maintainer’s working copy.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is out of scope here. You approve before any of that runs. If we're concerned about this, we should reject the script outright.

@eps1lon eps1lon Aug 13, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll see if we can disable the hooks but if that's your threat model, adopting a PR cannot work.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think this makes adoption impossible. The intended trust grant is to CI after review, not to contributor-controlled hooks on the maintainer's machine. Disabling hooks during checkout/rebase/push seems like cheap defense in depth in case review misses something.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all the commands now run with git hooks disabled.

Comment thread scripts/adopt-pr.js
const headerEnd = lines.indexOf('')
const headers = headerEnd === -1 ? lines : lines.slice(0, headerEnd)

if (!headers.some((line) => line.startsWith('gpgsig'))) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be an edge case, but a commit can contain a gpgsig header without GitHub considering the signature verified. (ie an unknown key or malformed signature). Should we re-sign unless GitHub reports it as verified?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can follow-up if that's an issue.

@eps1lon
eps1lon requested a review from ztanner August 13, 2026 15:06
@eps1lon
eps1lon force-pushed the sebbie/fork-pr-adoption-command-9c6eb4 branch 2 times, most recently from ad8e723 to 3c47d53 Compare August 13, 2026 15:11
Pull requests from forks run without repository secrets, so deploy tests never
run on external contributions. The workaround is to re-push the contributor's
commits to a branch in `vercel/next.js` and open a replacement pull request from
there, which is both fiddly to do by hand and genuinely dangerous, so this
change adds `scripts/adopt-pr.js` to encode the procedure.

The script takes a PR number, resolves which remote points at `vercel/next.js`
rather than assuming a name (a maintainer clone has it as `origin`, a fork-based
clone as `upstream`), checks out the contributor's commits without rewriting
them so authorship survives to the merge, pushes `adopt/<pr-number>`, and opens a
draft PR whose body is the contributor's description verbatim behind an
`Adopts #N. Closes #N.` line. That line closes the original on merge and
cross-links it. The description is copied rather than rewritten because it is the
contributor's own text, and because any `Fixes #123` inside it has to survive:
the original PR closes unmerged and so never fires its own.

The central piece is a confirmation gate. Adoption runs the contributor's code on
a branch where CI has repository secrets, which is exactly what a fork PR is
denied, so anything executing during install, build, or test can read and
exfiltrate them. Before touching git, the script prints the PR link, its status,
the author's handle and association, the diff size, and every file the PR
touches. That list is deliberately unranked: a payload can sit in any fixture or
source file, so marking some paths as risky would only imply the rest are safe.
It is gathered from the paginated REST endpoint because `gh pr view --json files`
silently caps at 100 entries, and a truncated list would read as the complete
surface. If the result still falls short of `changedFiles`, the shortfall is
reported rather than hidden. Confirming requires retyping the author's handle so
the adopter has to look at who they are trusting, and a non-interactive stdin is
refused outright rather than silently proceeding.

Draft and closed pull requests are both adoptable, since a contributor may still
be iterating or may have abandoned a change that went unreviewed, so their status
is surfaced in the header rather than enforced. Merged pull requests are refused
because their commits are already in `canary`. The remaining preflight checks
also run before anything mutates: the PR must come from a fork to be worth
adopting, the working tree must have no uncommitted changes to tracked files, and
the target branch must not already exist. Expected problems of that kind print as
plain messages, while unexpected failures keep their stack and `cause` chain.
The adopted pull request now inherits the original's base branch. It was being
opened against a hard-coded `canary` while the fetched `baseRefName` went unused,
so adopting a fork PR aimed at a release branch silently retargeted it. That is
not hypothetical: #96739 targets `next-15-5`, and adopting it would have redirected
a release-branch fix into `canary`. A base other than the default is highlighted
in the summary, since it changes what the change means.

Contributor commits usually arrive unsigned, and protected branches require
verified signatures, so the branch is re-signed before it is pushed. Signing
rewrites commits, so each `Author` is preserved and the tree is compared before
and after: re-signing must never alter content, and `--rebase-merges` is not
trusted to be content-preserving on faith. If the rebase fails the script aborts
it, and if the tree moves it resets and refuses to push.

Signature detection deliberately reads the raw commit headers rather than `%G?`.
That placeholder reports whether a signature *verifies*, not whether one exists,
so with SSH signing and no `gpg.ssh.allowedSignersFile` it reads `N` for every
commit, including freshly signed ones and GitHub's own signed merge commits.
On #97233 it reports `N` for both commits while the headers correctly distinguish
GitHub's signed merge from the contributor's unsigned commit.

The script now shells out through `execa` like the other scripts here, and the
`UsageError` class is gone in favour of plain errors. `pnpm pr-adopt` is wired up
as a shortcut.
Adoption fetched the pull request only after the adopter had reviewed and
confirmed it, and took whatever `refs/pull/<n>/head` pointed at by then. A
contributor could push in that window, so the code that reached a branch with
secrets access was not necessarily the code that was vouched for. The head SHA
is now captured with the rest of the metadata, shown in the confirmation
summary, and compared against what was actually fetched; a mismatch aborts
before anything is pushed. This is not a theoretical window: #97233 moved from
`c24c79db` to `5294c014`, and from 7 files to 9, over the course of writing this.

Checking out a fork branch also ran contributor code locally. `.husky/*` hook
scripts are tracked, so a pull request can add `.husky/post-checkout` or edit
`.husky/pre-commit`, and husky's shim executes `.husky/<hook>` from the working
tree. Checking the branch out, re-signing it and pushing it each fire hooks, and
`rebase --exec` was the worst of them because `git commit --amend` runs
`pre-commit` once per replayed commit. Every subprocess now runs with
`core.hooksPath` pointed at an empty directory. That is injected through
`GIT_CONFIG_COUNT` and friends rather than `-c`, because the environment is
inherited by the git processes that `gh` and `git rebase --exec` spawn, which is
where the exposure was. An existing `GIT_CONFIG_COUNT` is appended to rather
than overwritten.

So that no call site can opt out of this by accident, every subprocess goes
through one of three helpers, each of which passes that environment. Reaching
for `execa` directly to get `reject: false` would have quietly skipped hook
suppression, so `runAllowingFailure` covers that case and there is no remaining
reason to call `execa` anywhere else in the file.

The checkout now happens in a throwaway worktree under the system temp
directory, created from the pull request's base so it stays small, and removed
in a `finally` on success and failure alike. Contributor files and any
half-finished rebase never touch the maintainer's checkout, which is no longer
switched at all. The dirty-tree precondition is gone as a result: the working
copy is not involved, so it may be as dirty as the maintainer likes.
Working directly in the user's checkout switches their branch out from under
them and can leave a failed rebase behind, and it is the wrong place to put a
contributor's files when the branch is untrusted. This adds a section to
`AGENTS.md` telling agents to prefer a throwaway worktree and to remove it in a
cleanup path that also runs on failure.

The section carries the two things that make the practice work in this repo. A
fresh worktree has no `node_modules`, so `pnpm` and `npx` do not run in it until
the root one is symlinked in. That symlink still leaves per-package
`node_modules` and a built `packages/next/dist` missing, so `tsc --noEmit`
reports `TS2307` for dependencies like `fast-glob` and `dotenv`; those are
artifacts of the worktree rather than regressions, and the note says to confirm
against the main checkout instead of trying to fix them.

The "Linting and Types" section now also says to type-check with `pnpm
typescript` rather than a hand-rolled `tsconfig` aimed at one file. The root
config includes `scripts/**/*.js` and loads this repo's type augmentations, and
a config that misses them reports clean while CI fails. `NodeJS.ProcessEnv` is
the example, since `packages/next/types/global.d.ts` declares `NODE_ENV` as
required on it.
@eps1lon
eps1lon force-pushed the sebbie/fork-pr-adoption-command-9c6eb4 branch from 3c47d53 to a48007c Compare August 13, 2026 15:15
@eps1lon
eps1lon enabled auto-merge (squash) August 13, 2026 15:36
@eps1lon
eps1lon merged commit 9e1a290 into canary Aug 13, 2026
232 of 234 checks passed
@eps1lon
eps1lon deleted the sebbie/fork-pr-adoption-command-9c6eb4 branch August 13, 2026 15:52
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.

2 participants