next.js/scripts/patch-preview-tarball.mjs
patch-preview-tarball.mjs241 lines5.8 KB
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',
  '@next/swc-linux-x64-gnu',
]

// --- 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
  }

  // Add @next/swc-linux-x64-gnu to dependencies
  pkg.dependencies = pkg.dependencies || {}
  pkg.dependencies['@next/swc-linux-x64-gnu'] = tarballUrls.get(
    '@next/swc-linux-x64-gnu'
  )

  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)
})
Quest for Codev2.0.0
/
SIGN IN