From 2e179987f85e48b37b0f0fb802368d0fec6dec1d Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 24 Mar 2026 18:21:54 -0700 Subject: [PATCH 1/2] ci: upload e2e deploy adapter test results --- .github/workflows/test-e2e-deploy.yml | 55 ++++ scripts/upload-adapter-test-results.mjs | 326 ++++++++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 scripts/upload-adapter-test-results.mjs diff --git a/.github/workflows/test-e2e-deploy.yml b/.github/workflows/test-e2e-deploy.yml index 4dc7f7c..ee78fdc 100644 --- a/.github/workflows/test-e2e-deploy.yml +++ b/.github/workflows/test-e2e-deploy.yml @@ -129,3 +129,58 @@ jobs: NEXT_TEST_JOB: 1 NEXT_TELEMETRY_DISABLED: 1 run: node run-tests.js --timings -g ${{ matrix.group }} -c 2 --type e2e + + - name: Upload adapter test result artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: adapter-test-reports-${{ replace(matrix.group, '/', '-') }} + path: next.js/test/**/*.results.json + if-no-files-found: ignore + retention-days: 1 + + upload-adapter-test-results: + name: Upload adapter test results + needs: [test] + if: ${{ always() && (inputs.nextjsRef || 'canary') == 'canary' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - 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 bun \ + --commit-sha "${{ github.sha }}" + env: + ADAPTER_TEST_RESULTS_SECRET: ${{ secrets.ADAPTER_TEST_RESULTS_SECRET }} diff --git a/scripts/upload-adapter-test-results.mjs b/scripts/upload-adapter-test-results.mjs new file mode 100644 index 0000000..326c008 --- /dev/null +++ b/scripts/upload-adapter-test-results.mjs @@ -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/adapter-bun \\', + ' --provider bun \\', + ' --secret "$ADAPTER_TEST_RESULTS_SECRET" \\', + ' --commit-sha "$(git -C ~/dev/adapter-bun rev-parse HEAD)"', + '', + 'Required:', + ' --provider or ADAPTER_TEST_RESULTS_PROVIDER', + ' --secret or ADAPTER_TEST_RESULTS_SECRET', + ' --commit-sha or ADAPTER_TEST_RESULTS_COMMIT_SHA or GITHUB_SHA', + '', + 'Optional:', + ` --results-root default: ${process.cwd()}`, + ` --endpoint default: ${DEFAULT_ENDPOINT}`, + ' --zip-out write zip to disk before upload', + ' --dry-run only collect + zip; skip upload', + ' --help show this message', + ].join('\n') + ) +} + +function parseArgs(argv) { + /** @type {Record} */ + 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) +}) From f56c644316fdb72c9015f72589217b71b79a7f5f Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Thu, 26 Mar 2026 09:24:57 -0700 Subject: [PATCH 2/2] ci: only upload e2e test results on main --- .github/workflows/test-e2e-deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-e2e-deploy.yml b/.github/workflows/test-e2e-deploy.yml index ee78fdc..ae9505a 100644 --- a/.github/workflows/test-e2e-deploy.yml +++ b/.github/workflows/test-e2e-deploy.yml @@ -131,7 +131,7 @@ jobs: run: node run-tests.js --timings -g ${{ matrix.group }} -c 2 --type e2e - name: Upload adapter test result artifacts - if: ${{ always() }} + if: ${{ always() && github.ref == 'refs/heads/main' && (inputs.nextjsRef || 'canary') == 'canary' }} uses: actions/upload-artifact@v4 with: name: adapter-test-reports-${{ replace(matrix.group, '/', '-') }} @@ -142,7 +142,7 @@ jobs: upload-adapter-test-results: name: Upload adapter test results needs: [test] - if: ${{ always() && (inputs.nextjsRef || 'canary') == 'canary' }} + if: ${{ always() && github.ref == 'refs/heads/main' && (inputs.nextjsRef || 'canary') == 'canary' }} runs-on: ubuntu-latest steps: - name: Checkout