Repository: vercel/next.js
Pull request: ci: upload adapter deploy test results

Adds a final step to our e2e deploy test job to upload results for our adapter for comparison.

x-ref: https://github.com/vercel/next.js/actions/runs/23471649794/job/68298210825#step:5:40

Changed files:
filename: .github/workflows/build_reusable.yml
patch: @@ -79,6 +79,11 @@ on:
         required: false
         type: string
         default: ''
+      testReportsArtifactPrefix:
+        description: 'Artifact name prefix for uploading test `*.results.json` files. Empty string disables upload.'
+        required: false
+        type: string
+        default: ''
 
       browser:
         description: 'Browser to use for tests'
@@ -318,6 +323,15 @@ jobs:
         shell: bash -le {0}
         timeout-minutes: ${{ inputs.timeout_minutes }}
 
+      - name: Upload test result artifacts
+        if: ${{ inputs.testReportsArtifactPrefix != '' && inputs.afterBuild && always() }}
+        uses: actions/upload-artifact@v4
+        with:
+          name: ${{ inputs.testReportsArtifactPrefix }}-${{ steps.var.outputs.input_step_key }}
+          path: test/**/*.results.json
+          if-no-files-found: ignore
+          retention-days: 1
+
       # This file messes up the tests because it influences the build root autodetection.
       # Jest has a global cache, so PRs that poison the cache can bring down CI
       - name: Clean up stray files

filename: .github/workflows/test_e2e_deploy_release.yml
patch: @@ -220,6 +220,7 @@ jobs:
       skipNativeBuild: 'yes'
       skipNativeInstall: 'no'
       stepName: 'test-deploy-deploy-${{ matrix.group }}'
+      testReportsArtifactPrefix: 'adapter-test-reports'
       timeout_minutes: 180
       runs_on_labels: '["ubuntu-latest"]'
       overrideProxyAddress: ${{ inputs.overrideProxyAddress || '' }}
@@ -245,6 +246,53 @@ jobs:
             DD_ENV=ci npx @datadog/datadog-ci@2.23.1 junit upload --tags test.type:deploy --service nextjs ./test/test-junit-report
           fi
 
+  upload-adapter-test-results:
+    name: Upload adapter test results
+    needs: [test-deploy-adapter]
+    if: ${{ always() && github.event.inputs.deployScriptPath == '' && github.repository_owner == 'vercel' }}
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: 20.9.0
+          check-latest: true
+
+      - name: Download adapter test result artifacts
+        continue-on-error: true
+        uses: actions/download-artifact@v4
+        with:
+          pattern: adapter-test-reports-*
+          path: adapter-test-results
+          merge-multiple: true
+
+      - name: Upload adapter test results
+        run: |
+          if [ -z "${ADAPTER_TEST_RESULTS_SECRET:-}" ]; then
+            echo "ADAPTER_TEST_RESULTS_SECRET is not configured, skipping upload"
+            exit 0
+          fi
+
+          if [ ! -d adapter-test-results ]; then
+            echo "No adapter test artifact directory found, skipping upload"
+            exit 0
+          fi
+
+          if ! find adapter-test-results -type f -name '*.results.json' | grep -q .; then
+            echo "No adapter .results.json files found, skipping upload"
+            exit 0
+          fi
+
+          node scripts/upload-adapter-test-results.mjs \
+            --results-root adapter-test-results \
+            --provider vercel \
+            --commit-sha "${{ github.sha }}"
+        env:
+          ADAPTER_TEST_RESULTS_SECRET: ${{ secrets.ADAPTER_TEST_RESULTS_SECRET }}
+
   create-draft-prs:
     name: Immediately open draft prs
     needs: setup

filename: scripts/upload-adapter-test-results.mjs
patch: @@ -0,0 +1,326 @@
+import fs from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import { spawn } from 'node:child_process'
+
+const RESULT_FILE_SUFFIX = '.results.json'
+const DEFAULT_ENDPOINT = 'https://nextjs.org/api/adapter-test-results'
+const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,40}$/i
+const BOOLEAN_ARGS = new Set(['help', 'dry-run'])
+const IGNORED_DIRECTORIES = new Set([
+  '.git',
+  '.hg',
+  '.svn',
+  '.next',
+  '.turbo',
+  'node_modules',
+  'dist',
+  'out',
+  'coverage',
+])
+
+function usage() {
+  console.log(
+    [
+      'Collect Jest *.results.json files, zip them, and upload to the adapter test results endpoint.',
+      '',
+      'Usage:',
+      '  node scripts/upload-adapter-test-results.mjs \\',
+      '    --results-root ~/dev/next.js \\',
+      '    --provider vercel \\',
+      '    --secret "$ADAPTER_TEST_RESULTS_SECRET" \\',
+      '    --commit-sha "$(git -C ~/dev/next.js rev-parse HEAD)"',
+      '',
+      'Required:',
+      '  --provider <name>       or ADAPTER_TEST_RESULTS_PROVIDER',
+      '  --secret <secret>       or ADAPTER_TEST_RESULTS_SECRET',
+      '  --commit-sha <sha>      or ADAPTER_TEST_RESULTS_COMMIT_SHA or GITHUB_SHA',
+      '',
+      'Optional:',
+      `  --results-root <path>   default: ${process.cwd()}`,
+      `  --endpoint <url>        default: ${DEFAULT_ENDPOINT}`,
+      '  --zip-out <path>        write zip to disk before upload',
+      '  --dry-run               only collect + zip; skip upload',
+      '  --help                  show this message',
+    ].join('\n')
+  )
+}
+
+function parseArgs(argv) {
+  /** @type {Record<string, string | boolean>} */
+  const args = {}
+
+  for (let index = 2; index < argv.length; index += 1) {
+    const arg = argv[index]
+
+    if (arg === '--') {
+      continue
+    }
+
+    if (!arg.startsWith('--')) {
+      throw new Error(`Unexpected argument: ${arg}`)
+    }
+
+    const separatorIndex = arg.indexOf('=')
+    if (separatorIndex > -1) {
+      const key = arg.slice(2, separatorIndex)
+      const value = arg.slice(separatorIndex + 1)
+      args[key] = value
+      continue
+    }
+
+    const key = arg.slice(2)
+    if (BOOLEAN_ARGS.has(key)) {
+      args[key] = true
+      continue
+    }
+
+    const value = argv[index + 1]
+    if (!value || value.startsWith('--')) {
+      throw new Error(`Missing value for --${key}`)
+    }
+
+    args[key] = value
+    index += 1
+  }
+
+  return args
+}
+
+function resolvePathWithHome(inputPath) {
+  if (inputPath === '~') {
+    return os.homedir()
+  }
+
+  if (inputPath.startsWith('~/')) {
+    return path.join(os.homedir(), inputPath.slice(2))
+  }
+
+  return inputPath
+}
+
+function toZipEntryPath(filePath) {
+  return filePath.split(path.sep).join('/')
+}
+
+async function walkResultFiles(rootDir, currentDir, files) {
+  const entries = await fs.readdir(currentDir, { withFileTypes: true })
+
+  for (const entry of entries) {
+    const entryPath = path.join(currentDir, entry.name)
+
+    if (entry.isDirectory()) {
+      if (IGNORED_DIRECTORIES.has(entry.name)) {
+        continue
+      }
+
+      await walkResultFiles(rootDir, entryPath, files)
+      continue
+    }
+
+    if (!entry.isFile() || !entry.name.endsWith(RESULT_FILE_SUFFIX)) {
+      continue
+    }
+
+    const relativePath = path.relative(rootDir, entryPath)
+    files.push({
+      absolutePath: entryPath,
+      relativePath: toZipEntryPath(relativePath),
+    })
+  }
+}
+
+async function collectResultFiles(resultsRoot) {
+  /** @type {{ absolutePath: string; relativePath: string }[]} */
+  const files = []
+  await walkResultFiles(resultsRoot, resultsRoot, files)
+  files.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
+  return files
+}
+
+function formatBytes(value) {
+  if (value < 1024) {
+    return `${value} B`
+  }
+
+  if (value < 1024 * 1024) {
+    return `${(value / 1024).toFixed(1)} KiB`
+  }
+
+  return `${(value / (1024 * 1024)).toFixed(2)} MiB`
+}
+
+async function runZipCommand(resultsRoot, files, outputZipPath) {
+  const relativePaths = files.map((file) => file.relativePath)
+
+  await new Promise((resolve, reject) => {
+    const child = spawn('zip', ['-q', '-9', outputZipPath, '-@'], {
+      cwd: resultsRoot,
+      stdio: ['pipe', 'ignore', 'pipe'],
+    })
+
+    let stderr = ''
+
+    child.on('error', (error) => {
+      reject(new Error(`Failed to run zip: ${error.message}`))
+    })
+
+    child.stderr.on('data', (chunk) => {
+      stderr += String(chunk)
+    })
+
+    child.on('close', (code, signal) => {
+      if (code === 0) {
+        resolve(undefined)
+        return
+      }
+
+      reject(
+        new Error(
+          [
+            `zip failed with code ${code}${signal ? ` and signal ${signal}` : ''}.`,
+            stderr.trim(),
+          ]
+            .filter(Boolean)
+            .join(' ')
+        )
+      )
+    })
+
+    child.stdin.write(`${relativePaths.join('\n')}\n`)
+    child.stdin.end()
+  })
+}
+
+async function createArchive(resultsRoot, files) {
+  const tempDir = await fs.mkdtemp(
+    path.join(os.tmpdir(), 'adapter-test-results-')
+  )
+  const zipPath = path.join(tempDir, 'results.zip')
+
+  try {
+    await runZipCommand(resultsRoot, files, zipPath)
+    return new Uint8Array(await fs.readFile(zipPath))
+  } finally {
+    await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {})
+  }
+}
+
+async function main() {
+  const args = parseArgs(process.argv)
+
+  if (args.help) {
+    usage()
+    return
+  }
+
+  const provider =
+    args.provider || process.env.ADAPTER_TEST_RESULTS_PROVIDER || ''
+  const secret = args.secret || process.env.ADAPTER_TEST_RESULTS_SECRET || ''
+  const commitSha =
+    args['commit-sha'] ||
+    process.env.ADAPTER_TEST_RESULTS_COMMIT_SHA ||
+    process.env.GITHUB_SHA ||
+    ''
+  const endpoint =
+    args.endpoint ||
+    process.env.ADAPTER_TEST_RESULTS_ENDPOINT ||
+    DEFAULT_ENDPOINT
+  const inputRoot = args['results-root'] || process.cwd()
+  const resultsRoot = path.resolve(resolvePathWithHome(String(inputRoot)))
+  const zipOutArg = args['zip-out']
+  const zipOutPath = zipOutArg
+    ? path.resolve(resolvePathWithHome(String(zipOutArg)))
+    : null
+  const dryRun = Boolean(args['dry-run'])
+
+  if (!provider) {
+    throw new Error(
+      'Missing provider. Pass --provider or ADAPTER_TEST_RESULTS_PROVIDER.'
+    )
+  }
+
+  if (!secret) {
+    throw new Error(
+      'Missing secret. Pass --secret or ADAPTER_TEST_RESULTS_SECRET.'
+    )
+  }
+
+  if (!commitSha || !COMMIT_SHA_PATTERN.test(String(commitSha))) {
+    throw new Error(
+      'Invalid commit SHA. Pass --commit-sha (7-40 hex chars), ADAPTER_TEST_RESULTS_COMMIT_SHA, or GITHUB_SHA.'
+    )
+  }
+
+  const stat = await fs.stat(resultsRoot).catch(() => null)
+  if (!stat || !stat.isDirectory()) {
+    throw new Error(
+      `results-root does not exist or is not a directory: ${resultsRoot}`
+    )
+  }
+
+  console.log(`Collecting ${RESULT_FILE_SUFFIX} files from: ${resultsRoot}`)
+  const resultFiles = await collectResultFiles(resultsRoot)
+
+  if (resultFiles.length === 0) {
+    throw new Error(`No ${RESULT_FILE_SUFFIX} files found under ${resultsRoot}`)
+  }
+
+  console.log(`Found ${resultFiles.length} result files`)
+  const archive = await createArchive(resultsRoot, resultFiles)
+  console.log(`Created archive (${formatBytes(archive.byteLength)})`)
+
+  if (zipOutPath) {
+    await fs.mkdir(path.dirname(zipOutPath), { recursive: true })
+    await fs.writeFile(zipOutPath, archive)
+    console.log(`Wrote archive to: ${zipOutPath}`)
+  }
+
+  if (dryRun) {
+    console.log('Dry run complete (skipping upload).')
+    return
+  }
+
+  console.log(`Uploading to: ${endpoint}`)
+  const response = await fetch(endpoint, {
+    method: 'POST',
+    headers: {
+      'content-type': 'application/zip',
+      'x-adapter-test-results-provider': String(provider),
+      'x-adapter-test-results-secret': String(secret),
+      'x-adapter-test-results-commit-sha': String(commitSha),
+    },
+    body: archive,
+  })
+
+  const responseText = await response.text()
+  let parsedResponse
+
+  try {
+    parsedResponse = JSON.parse(responseText)
+  } catch {
+    parsedResponse = responseText
+  }
+
+  if (!response.ok) {
+    throw new Error(
+      `Upload failed (${response.status} ${response.statusText}): ${
+        typeof parsedResponse === 'string'
+          ? parsedResponse
+          : JSON.stringify(parsedResponse)
+      }`
+    )
+  }
+
+  console.log('Upload succeeded')
+  if (typeof parsedResponse === 'string') {
+    console.log(parsedResponse)
+  } else {
+    console.log(JSON.stringify(parsedResponse, null, 2))
+  }
+}
+
+main().catch((error) => {
+  console.error(error instanceof Error ? error.message : String(error))
+  process.exit(1)
+})