Repository: vercel/next.js
Pull request: Add script to patch project with preview tarball URLs

### What?

Adds a new script `scripts/patch-preview-tarball.mjs` that patches a target project's `package.json` to use Next.js preview tarballs from `vercel-packages.vercel.app`.

### Why?

When testing unreleased Next.js changes against external projects (e.g., reproducing bugs, verifying fixes), there's no convenient way to point a project at a specific Next.js commit's build artifacts. This script automates the process of wiring up preview tarball URLs into a project's dependency overrides.

### How?

The script:

1. **Resolves a commit SHA** via one of three methods:
   - `--commit <sha>` — use an explicit commit
   - `--branch <name>` — look up the branch tip via the GitHub API (`gh`)
   - *(default)* — use the local `git rev-parse HEAD`

2. **Builds tarball URLs** for `next`, `@next/mdx`, `@next/env`, and `@next/bundle-analyzer` using the `vercel-packages.vercel.app/next/commits/<sha>/<pkg>` pattern.

3. **Verifies** that the preview tarball exists (HEAD request to the `next` tarball URL).

4. **Patches `package.json`** in the target project (or its workspace root) by setting both `overrides` (npm/pnpm) and `resolutions` (yarn) for the packages.

**Usage:**

```bash
node scripts/patch-preview-tarball.mjs --project <path> [--commit <sha> | --branch <name>]
```

Changed files:
filename: scripts/patch-preview-tarball.mjs
patch: @@ -0,0 +1,233 @@
+import { execSync } from 'node:child_process'
+import fs from 'node:fs/promises'
+import path from 'node:path'
+import { parseArgs } from 'node:util'
+
+const BASE_URL = 'https://vercel-packages.vercel.app/next/commits'
+
+const PACKAGES_TO_PATCH = [
+  'next',
+  '@next/mdx',
+  '@next/env',
+  '@next/bundle-analyzer',
+]
+
+// --- Argument parsing ---
+
+function parseAndValidateArgs() {
+  const { values } = parseArgs({
+    options: {
+      project: { type: 'string' },
+      commit: { type: 'string' },
+      branch: { type: 'string' },
+    },
+    strict: true,
+  })
+
+  if (!values.project) {
+    console.error(
+      'Usage: node scripts/patch-preview-tarball.mjs --project <path> [--commit <sha> | --branch <name>]'
+    )
+    process.exit(1)
+  }
+
+  if (values.commit && values.branch) {
+    console.error('Error: --commit and --branch are mutually exclusive.')
+    process.exit(1)
+  }
+
+  return {
+    project: path.resolve(values.project),
+    commit: values.commit,
+    branch: values.branch,
+  }
+}
+
+// --- Resolve commit SHA ---
+
+function resolveCommitSha({ commit, branch }) {
+  if (commit) {
+    if (!/^[0-9a-f]{7,40}$/i.test(commit)) {
+      console.error(`Error: Invalid commit SHA: ${commit}`)
+      process.exit(1)
+    }
+    return commit
+  }
+
+  if (branch) {
+    const encoded = encodeURIComponent(branch)
+    try {
+      const sha = execSync(
+        `gh api "repos/vercel/next.js/branches/${encoded}" --jq '.commit.sha'`,
+        { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
+      ).trim()
+      if (!sha) {
+        console.error(
+          `Error: Could not resolve branch '${branch}' to a commit SHA.`
+        )
+        process.exit(1)
+      }
+      return sha
+    } catch (err) {
+      console.error(
+        `Error: Failed to look up branch '${branch}' via GitHub API.`
+      )
+      console.error(err.stderr?.toString() || err.message)
+      process.exit(1)
+    }
+  }
+
+  // Fallback: local HEAD
+  try {
+    return execSync('git rev-parse HEAD', {
+      encoding: 'utf8',
+      stdio: ['pipe', 'pipe', 'pipe'],
+    }).trim()
+  } catch (err) {
+    console.error('Error: Failed to resolve local HEAD commit.')
+    console.error(err.stderr?.toString() || err.message)
+    process.exit(1)
+  }
+}
+
+// --- URL construction ---
+
+function buildTarballUrls(commitSha) {
+  const urls = new Map()
+  for (const pkg of PACKAGES_TO_PATCH) {
+    urls.set(pkg, `${BASE_URL}/${commitSha}/${pkg}`)
+  }
+  return urls
+}
+
+// --- Tarball verification ---
+
+async function verifyTarballExists(url) {
+  try {
+    const res = await fetch(url, { method: 'HEAD', redirect: 'follow' })
+    return res.ok
+  } catch {
+    return false
+  }
+}
+
+// --- Workspace root finding ---
+
+async function fileExists(filePath) {
+  try {
+    await fs.access(filePath)
+    return true
+  } catch {
+    return false
+  }
+}
+
+async function findWorkspaceRoot(projectPath) {
+  for (const ev of ['NPM_CONFIG_WORKSPACE_DIR', 'npm_config_workspace_dir']) {
+    if (process.env[ev]) {
+      return process.env[ev]
+    }
+  }
+
+  try {
+    const canonicalPath = await fs.realpath(projectPath)
+    let currentDir = canonicalPath
+
+    while (currentDir !== path.parse(currentDir).root) {
+      if (await fileExists(path.join(currentDir, 'pnpm-workspace.yaml'))) {
+        return currentDir
+      }
+
+      const packageJsonPath = path.join(currentDir, 'package.json')
+      if (await fileExists(packageJsonPath)) {
+        const content = await fs.readFile(packageJsonPath, 'utf8')
+        const pkg = JSON.parse(content)
+        if (pkg.workspaces) {
+          return currentDir
+        }
+      }
+
+      currentDir = path.dirname(currentDir)
+    }
+
+    return null
+  } catch {
+    return null
+  }
+}
+
+// --- Patch package.json ---
+
+async function patchPackageJson(projectPath, tarballUrls) {
+  const root = await findWorkspaceRoot(projectPath)
+  const packageJsonPath = root
+    ? path.join(root, 'package.json')
+    : path.join(projectPath, 'package.json')
+
+  if (!(await fileExists(packageJsonPath))) {
+    console.error(`Error: package.json not found at ${packageJsonPath}`)
+    process.exit(1)
+  }
+
+  const content = await fs.readFile(packageJsonPath, 'utf8')
+  const pkg = JSON.parse(content)
+
+  const entries = Array.from(tarballUrls.entries())
+
+  // npm/pnpm overrides
+  pkg.overrides = pkg.overrides || {}
+  for (const [name, url] of entries) {
+    pkg.overrides[name] = url
+  }
+
+  // yarn resolutions
+  pkg.resolutions = pkg.resolutions || {}
+  for (const [name, url] of entries) {
+    pkg.resolutions[name] = url
+  }
+
+  await fs.writeFile(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n')
+
+  console.log(`Patched ${packageJsonPath}`)
+  console.log('Packages overridden:')
+  for (const [name, url] of entries) {
+    console.log(`  ${name} -> ${url}`)
+  }
+
+  return packageJsonPath
+}
+
+// --- Main ---
+
+async function main() {
+  const { project, commit, branch } = parseAndValidateArgs()
+
+  const sha = resolveCommitSha({ commit, branch })
+  console.log(`Resolved commit SHA: ${sha}`)
+
+  const tarballUrls = buildTarballUrls(sha)
+
+  const nextUrl = tarballUrls.get('next')
+  console.log(`Verifying preview tarball exists: ${nextUrl}`)
+  const exists = await verifyTarballExists(nextUrl)
+  if (!exists) {
+    console.error(
+      `Preview tarball not found for commit ${sha}.\n` +
+        `The "Deploy preview tarball" job may not have completed yet, or the commit may not have a build.\n` +
+        `Check: https://github.com/vercel/next.js/actions/workflows/build_and_deploy.yml`
+    )
+    process.exit(1)
+  }
+  console.log('Preview tarball verified.')
+
+  await patchPackageJson(project, tarballUrls)
+
+  console.log(
+    '\nDone! Run your package manager install command to apply changes.'
+  )
+}
+
+main().catch((err) => {
+  console.error(err)
+  process.exit(1)
+})