|
| 1 | +# Temporary workflow: re-run the `Generate report` + `Submit results to |
| 2 | +# /compatibility ingest` steps from .github/workflows/nextjs-deploy-suite.yml |
| 3 | +# against the artifacts of a previously-completed nightly run whose ingest |
| 4 | +# submission was broken (e.g. run 25925392641, which hit a DNS error before |
| 5 | +# the URL fix landed). |
| 6 | +# |
| 7 | +# The steps below are intentionally copied byte-for-byte from |
| 8 | +# nextjs-deploy-suite.yml's `report` job. The only difference is the artifact |
| 9 | +# source: instead of `actions/download-artifact` pulling from the current |
| 10 | +# run's matrix, we use the `run-id:` field to pull from the target run. |
| 11 | +# |
| 12 | +# Delete this workflow (and its branch) once the backfill is done. |
| 13 | + |
| 14 | +name: Backfill /compatibility ingest |
| 15 | + |
| 16 | +on: |
| 17 | + push: |
| 18 | + branches: |
| 19 | + - opencode/backfill-compat-ingest |
| 20 | + workflow_dispatch: |
| 21 | + inputs: |
| 22 | + run-id: |
| 23 | + description: nextjs-deploy-suite run id to reprocess |
| 24 | + required: true |
| 25 | + type: string |
| 26 | + |
| 27 | +permissions: |
| 28 | + contents: read |
| 29 | + actions: read |
| 30 | + |
| 31 | +jobs: |
| 32 | + backfill: |
| 33 | + name: Backfill ingest for ${{ inputs.run-id || '25925392641' }} |
| 34 | + if: github.repository == 'cloudflare/vinext' |
| 35 | + runs-on: ubuntu-latest |
| 36 | + timeout-minutes: 10 |
| 37 | + |
| 38 | + steps: |
| 39 | + - name: Download all test results |
| 40 | + uses: actions/download-artifact@v7 |
| 41 | + with: |
| 42 | + pattern: test-results-* |
| 43 | + path: results |
| 44 | + merge-multiple: true |
| 45 | + # Pull from the specified historical run instead of the current one. |
| 46 | + run-id: ${{ inputs.run-id || '25925392641' }} |
| 47 | + github-token: ${{ secrets.GITHUB_TOKEN }} |
| 48 | + |
| 49 | + # Verbatim copy of the `Generate report` step from nextjs-deploy-suite.yml. |
| 50 | + # Producing `report/compat-ingest.json` is what we actually need; the |
| 51 | + # rest (job summary, failed-tests table) is harmless on a backfill. |
| 52 | + - name: Generate report |
| 53 | + id: report |
| 54 | + uses: actions/github-script@v7 |
| 55 | + env: |
| 56 | + # Use the source run's id as the runKey so the upsert lands on the |
| 57 | + # same row the original submission would have produced. |
| 58 | + SOURCE_RUN_ID: ${{ inputs.run-id || '25925392641' }} |
| 59 | + VINEXT_REF: main |
| 60 | + NEXT_REF: v16.2.6 |
| 61 | + SUITE_FILTER: all |
| 62 | + with: |
| 63 | + script: | |
| 64 | + const fs = require('node:fs'); |
| 65 | + const path = require('node:path'); |
| 66 | +
|
| 67 | + function findResultFiles(dir) { |
| 68 | + const files = []; |
| 69 | + if (!fs.existsSync(dir)) return files; |
| 70 | + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { |
| 71 | + const full = path.join(dir, entry.name); |
| 72 | + if (entry.isDirectory()) { |
| 73 | + files.push(...findResultFiles(full)); |
| 74 | + } else if (entry.name.endsWith('.results.json')) { |
| 75 | + files.push(full); |
| 76 | + } |
| 77 | + } |
| 78 | + return files; |
| 79 | + } |
| 80 | +
|
| 81 | + const resultFiles = findResultFiles('results'); |
| 82 | + if (resultFiles.length === 0) { |
| 83 | + core.summary.addHeading('Next.js Deploy Suite', 2); |
| 84 | + core.summary.addRaw('No test result files found. All shards may have been skipped or produced no output.'); |
| 85 | + await core.summary.write(); |
| 86 | + return; |
| 87 | + } |
| 88 | +
|
| 89 | + const passed = []; |
| 90 | + const failed = []; |
| 91 | + const skipped = []; |
| 92 | + // Per-test-file counts for the /compatibility page ingest. |
| 93 | + const fileCounts = new Map(); |
| 94 | + function bumpFile(suite, key) { |
| 95 | + let cur = fileCounts.get(suite); |
| 96 | + if (!cur) { |
| 97 | + cur = { suite, total: 0, passed: 0, failed: 0, skipped: 0 }; |
| 98 | + fileCounts.set(suite, cur); |
| 99 | + } |
| 100 | + cur.total++; |
| 101 | + cur[key]++; |
| 102 | + } |
| 103 | +
|
| 104 | + // Derive a canonical Next.js test path from the result-file path. |
| 105 | + // |
| 106 | + // Next.js's run-tests.js does NOT populate testResults[].testFilePath |
| 107 | + // in the JSON, so we have to recover it from where the file lives in |
| 108 | + // the artifact tree. Each shard uploads files at their relative path |
| 109 | + // inside next.js/test/, e.g. `e2e/app-dir/foo/foo.test.ts.results.json`. |
| 110 | + // |
| 111 | + // The report job uses `merge-multiple: true`, which flattens the |
| 112 | + // contents of every artifact into `results/`. We also defensively |
| 113 | + // strip a leading `test-results-N/` segment in case that ever |
| 114 | + // changes and shards get nested under their artifact names. |
| 115 | + // Result: a canonical path of the form `test/e2e/app-dir/foo/foo.test.ts`. |
| 116 | + function deriveSuiteName(file) { |
| 117 | + let rel = path.relative('results', file); |
| 118 | + // Defensive: strip a leading `test-results-<n>/` shard prefix if |
| 119 | + // the download layout ever changes. |
| 120 | + rel = rel.replace(/^test-results-[^/]+\//, ''); |
| 121 | + const stripped = rel.replace(/\.results\.json$/, ''); |
| 122 | + if (stripped && stripped.includes('/')) { |
| 123 | + return `test/${stripped}`; |
| 124 | + } |
| 125 | + // Fallback: file sits at the top level of results/ (shouldn't |
| 126 | + // happen in practice). Use the basename so we don't break ingest. |
| 127 | + return path.basename(file, '.results.json'); |
| 128 | + } |
| 129 | +
|
| 130 | + for (const file of resultFiles) { |
| 131 | + try { |
| 132 | + const data = JSON.parse(fs.readFileSync(file, 'utf8')); |
| 133 | + const suiteName = deriveSuiteName(file); |
| 134 | +
|
| 135 | + for (const suite of data.testResults || []) { |
| 136 | + for (const tc of suite.assertionResults || []) { |
| 137 | + const testName = tc.ancestorTitles |
| 138 | + ? [...tc.ancestorTitles, tc.title].join(' > ') |
| 139 | + : tc.fullName || tc.title; |
| 140 | +
|
| 141 | + if (tc.status === 'passed') { |
| 142 | + passed.push({ suite: suiteName, test: testName }); |
| 143 | + bumpFile(suiteName, 'passed'); |
| 144 | + } else if (tc.status === 'failed') { |
| 145 | + const msg = (tc.failureMessages || []).join('\n').slice(0, 500); |
| 146 | + failed.push({ suite: suiteName, test: testName, message: msg }); |
| 147 | + bumpFile(suiteName, 'failed'); |
| 148 | + } else { |
| 149 | + skipped.push({ suite: suiteName, test: testName }); |
| 150 | + bumpFile(suiteName, 'skipped'); |
| 151 | + } |
| 152 | + } |
| 153 | + } |
| 154 | + } catch (e) { |
| 155 | + core.warning(`Failed to parse ${file}: ${e.message}`); |
| 156 | + } |
| 157 | + } |
| 158 | +
|
| 159 | + // Persist the per-file counts for the next step to submit to D1. |
| 160 | + // Use the SOURCE run id (not this backfill workflow's run id) as |
| 161 | + // the runKey, so the row upserts onto whatever the original |
| 162 | + // submission would have written. |
| 163 | + const sourceRunId = process.env.SOURCE_RUN_ID; |
| 164 | + fs.mkdirSync('report', { recursive: true }); |
| 165 | + fs.writeFileSync( |
| 166 | + 'report/compat-ingest.json', |
| 167 | + JSON.stringify( |
| 168 | + { |
| 169 | + kind: 'deploy', |
| 170 | + runKey: sourceRunId, |
| 171 | + vinextRef: process.env.VINEXT_REF, |
| 172 | + nextRef: process.env.NEXT_REF, |
| 173 | + commitSha: null, |
| 174 | + files: Array.from(fileCounts.values()), |
| 175 | + }, |
| 176 | + null, |
| 177 | + 2, |
| 178 | + ) + '\n', |
| 179 | + ); |
| 180 | +
|
| 181 | + const total = passed.length + failed.length + skipped.length; |
| 182 | + const passRate = total > 0 ? ((passed.length / total) * 100).toFixed(1) : '0.0'; |
| 183 | + core.summary.addHeading('Backfill', 2); |
| 184 | + core.summary.addRaw( |
| 185 | + `Reprocessed run \`${sourceRunId}\`: **${passed.length}** passed, **${failed.length}** failed, **${skipped.length}** skipped (${total} total, ${passRate}% pass rate).\n` |
| 186 | + ); |
| 187 | + await core.summary.write(); |
| 188 | +
|
| 189 | + # Verbatim copy of the `Submit results to /compatibility ingest` step, |
| 190 | + # minus the suite-filter/main-branch guard (we wouldn't be running this |
| 191 | + # workflow at all if the source run wasn't a main-branch full-suite run). |
| 192 | + - name: Submit results to /compatibility ingest |
| 193 | + if: hashFiles('report/compat-ingest.json') != '' |
| 194 | + env: |
| 195 | + COMPAT_INGEST_URL: ${{ vars.COMPAT_INGEST_URL || 'https://vinext-web.vinext.workers.dev/api/compatibility' }} |
| 196 | + COMPAT_INGEST_SECRET: ${{ secrets.COMPAT_INGEST_SECRET }} |
| 197 | + run: | |
| 198 | + if [ -z "${COMPAT_INGEST_SECRET:-}" ]; then |
| 199 | + echo "::error::COMPAT_INGEST_SECRET is not configured." |
| 200 | + exit 1 |
| 201 | + fi |
| 202 | + echo "Submitting compat results to ${COMPAT_INGEST_URL}" |
| 203 | + if ! curl --silent --show-error --fail-with-body \ |
| 204 | + -X POST "${COMPAT_INGEST_URL}" \ |
| 205 | + -H "Content-Type: application/json" \ |
| 206 | + -H "X-Compat-Secret: ${COMPAT_INGEST_SECRET}" \ |
| 207 | + --data-binary @report/compat-ingest.json; then |
| 208 | + echo "::error::Compatibility ingest POST failed." |
| 209 | + exit 1 |
| 210 | + fi |
0 commit comments