diff --git a/.github/scripts/check-test-growth.mjs b/.github/scripts/check-test-growth.mjs new file mode 100644 index 00000000000..b79fb8cd215 --- /dev/null +++ b/.github/scripts/check-test-growth.mjs @@ -0,0 +1,281 @@ +#!/usr/bin/env node +/** + * Report how much test code a pull request adds, and flag large additions. + * + * Nothing else counts test lines: the size rules in AGENTS.md and the triage + * skill count production lines only. In September 2026, PRs added 1.84 test + * lines per production line in core, cli and web-shell, and the 8% of PRs + * adding 2,000 or more net test lines carried half of that growth (#13007 + * removed 178,000 Core test lines, a few weeks of it). This step writes the + * numbers to the job summary on every PR and, at FLAG_THRESHOLD net test + * lines, emits a warning annotation so authors and reviewers see them. It + * reports only: nothing blocks on it. + * + * The diff is taken from the true merge base, which the compare API reports: + * the PR's base.sha is the base tip, and diffing against it would count the + * base's newer changes, reversed, on any branch that is behind. The lane's + * checkout is shallow, so the merge base is fetched at depth 1. + * + * Advisory only: every error is a warning and the step exits 0. + */ +import { execFile } from 'node:child_process'; +import { appendFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +export const FLAG_THRESHOLD = 2000; + +export const PACKAGES = ['core', 'cli', 'web-shell']; + +// Test code: test and spec files, test helpers, fixtures, mocks, snapshots. +const TEST_PATTERNS = [ + /\.(test|spec)\.[^/]+$/, + /\.test-helper\.[^/]+$/, + /\.fixtures\.json$/, + /\.snap$/, + /(^|\/)(test-setup|testUtils)\.ts$/, + /(^|\/)test-utils\.[^/]+$/, + /(^|\/)(__tests__|__mocks__|__fixtures__|__snapshots__|__e2e__|fixtures|test-utils|testUtils)\//, + /^packages\/web-shell\/client\/(e2e|test)\//, +]; +const GENERATED = /(\.schema\.(ts|json)|\.generated\.ts)$|(^|\/)generated\//; +const SOURCE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/; + +/** The package a path belongs to, or null outside the three packages. */ +export function packageOf(path) { + const match = /^packages\/([^/]+)\//.exec(path); + return match && PACKAGES.includes(match[1]) ? match[1] : null; +} + +/** 'test', 'production' or 'other' (data, docs, styles, generated code). */ +export function classify(path) { + if (TEST_PATTERNS.some((pattern) => pattern.test(path))) return 'test'; + if (GENERATED.test(path) || !SOURCE.test(path)) return 'other'; + return 'production'; +} + +/** Parses `git diff --numstat`, skipping binary files ("-\t-\tpath"). */ +export function parseNumstat(text) { + const entries = []; + for (const line of text.split('\n')) { + const match = /^(\d+)\t(\d+)\t(.+)$/.exec(line); + if (match) { + entries.push({ added: +match[1], deleted: +match[2], path: match[3] }); + } + } + return entries; +} + +/** Added and deleted test and production lines per package, plus a total. */ +export function tally(entries) { + const zero = () => ({ + test: { added: 0, deleted: 0 }, + production: { added: 0, deleted: 0 }, + }); + const byPackage = Object.fromEntries(PACKAGES.map((name) => [name, zero()])); + const total = zero(); + for (const { added, deleted, path } of entries) { + const name = packageOf(path); + const kind = classify(path); + if (!name || kind === 'other') continue; + for (const bucket of [byPackage[name][kind], total[kind]]) { + bucket.added += added; + bucket.deleted += deleted; + } + } + return { byPackage, total }; +} + +const net = ({ added, deleted }) => added - deleted; +const fmt = (n) => Math.abs(n).toLocaleString('en-US'); +const signed = (n) => (n > 0 ? '+' : n < 0 ? '−' : '') + fmt(n); +const cell = (counts) => + `+${fmt(counts.added)} / −${fmt(counts.deleted)} (net ${signed(net(counts))})`; + +export function isFlagged({ total }) { + return net(total.test) >= FLAG_THRESHOLD; +} + +export function renderSummary(tallied) { + const rows = PACKAGES.map( + (name) => + `| ${name} | ${cell(tallied.byPackage[name].test)} | ` + + `${cell(tallied.byPackage[name].production)} |`, + ); + const lines = [ + '### Test growth', + '', + '| Package | Test lines | Production lines |', + '| --- | ---: | ---: |', + ...rows, + `| **Total** | ${cell(tallied.total.test)} | ${cell(tallied.total.production)} |`, + '', + ]; + if (isFlagged(tallied)) { + lines.push( + `This PR adds ${fmt(net(tallied.total.test))} net test lines, at or ` + + `above the ${fmt(FLAG_THRESHOLD)}-line flag. Consider whether shared ` + + 'fixtures, parameterized tables or whole-result expectations would ' + + 'cover the same behavior in less code.', + '', + ); + } + return lines.join('\n'); +} + +export function renderAnnotation(tallied) { + if (!isFlagged(tallied)) return null; + return ( + `::warning title=Test growth::This PR adds ` + + `${fmt(net(tallied.total.test))} net test lines in core, cli and ` + + `web-shell (flag at ${fmt(FLAG_THRESHOLD)}). ` + + 'Consider shared fixtures or parameterized tables; see the job summary.' + ); +} + +/** + * curl, not fetch: it honours HTTPS_PROXY on the ECS runners' egress, which + * node's global fetch does not (see check-lint-gate-freshness.mjs). + */ +export async function curlJson(path, token, run = execFileAsync) { + try { + const { stdout } = await run( + 'curl', + [ + '-sS', + '-fL', + '--max-time', + '30', + '-H', + `Authorization: Bearer ${token}`, + '-H', + 'Accept: application/vnd.github+json', + '-H', + 'X-GitHub-Api-Version: 2022-11-28', + `https://api.github.com${path}`, + ], + { maxBuffer: 64 * 1024 * 1024 }, + ); + return JSON.parse(stdout); + } catch (error) { + // execFile's message embeds the full argv, bearer token included, so + // rebuild the error from the exit code and curl's last stderr line. + const stderr = String(error.stderr ?? '') + .trim() + .split('\n') + .pop(); + throw new Error( + `GET ${path} failed (curl exit ${error.code ?? '?'})${stderr ? `: ${stderr}` : ''}`, + ); + } +} + +async function git(...args) { + const { stdout } = await execFileAsync('git', args, { + maxBuffer: 64 * 1024 * 1024, + timeout: 120_000, + }); + return stdout; +} + +// For one command at a time: fetch the merge base's commit and trees only, +// and let `git diff` pull just the blobs it compares. A plain depth-1 fetch +// would transfer the whole tree again, about 60 MiB, on every PR. +const PARTIAL = [ + '-c', + 'extensions.partialClone=origin', + '-c', + 'remote.origin.promisor=true', +]; + +/** Net test and production lines between the PR's merge base and its head. */ +export async function measure({ fetchJson, runGit, repo, baseSha, headSha }) { + const compare = await fetchJson( + `/repos/${repo}/compare/${baseSha}...${headSha}?per_page=1`, + ); + const mergeBase = compare?.merge_base_commit?.sha; + if (!mergeBase) throw new Error('compare API returned no merge base'); + let partial = false; + try { + await runGit('cat-file', '-e', `${mergeBase}^{commit}`); + } catch { + const fetch = ['fetch', '--no-tags', '--depth=1']; + try { + await runGit( + ...PARTIAL, + ...fetch, + '--filter=blob:none', + 'origin', + mergeBase, + ); + partial = true; + } catch { + await runGit(...fetch, 'origin', mergeBase); + } + } + try { + const numstat = await runGit( + ...(partial ? PARTIAL : []), + 'diff', + '--numstat', + '--no-renames', + mergeBase, + headSha, + '--', + ...PACKAGES.map((name) => `packages/${name}`), + ); + return { mergeBase, tallied: tally(parseNumstat(numstat)) }; + } finally { + // The filtered fetch records its filter on the remote; drop it so later + // fetches in this checkout stay ordinary. + if (partial) { + await runGit( + 'config', + '--unset', + 'remote.origin.partialclonefilter', + ).catch(() => {}); + } + } +} + +async function main() { + const { GITHUB_TOKEN, BASE_SHA, HEAD_SHA, GITHUB_REPOSITORY } = process.env; + if (!GITHUB_TOKEN || !BASE_SHA || !HEAD_SHA || !GITHUB_REPOSITORY) { + console.log( + '::warning::test growth: BASE_SHA, HEAD_SHA, GITHUB_REPOSITORY or ' + + 'GITHUB_TOKEN missing; skipping the check', + ); + return; + } + try { + const { mergeBase, tallied } = await measure({ + fetchJson: (path) => curlJson(path, GITHUB_TOKEN), + runGit: git, + repo: GITHUB_REPOSITORY, + baseSha: BASE_SHA, + headSha: HEAD_SHA, + }); + const summary = renderSummary(tallied); + console.log(`Merge base ${mergeBase.slice(0, 12)}\n\n${summary}`); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`); + } + const annotation = renderAnnotation(tallied); + if (annotation) console.log(annotation); + } catch (error) { + // One line: an annotation ends at the first newline. + const reason = String(error.message).split('\n')[0]; + console.log( + `::warning::test growth check could not run (${reason}); skipping it`, + ); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + await main(); +} diff --git a/.github/scripts/check-test-growth.test.mjs b/.github/scripts/check-test-growth.test.mjs new file mode 100644 index 00000000000..80a98de02be --- /dev/null +++ b/.github/scripts/check-test-growth.test.mjs @@ -0,0 +1,216 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { + FLAG_THRESHOLD, + classify, + curlJson, + measure, + packageOf, + parseNumstat, + renderAnnotation, + renderSummary, + tally, +} from './check-test-growth.mjs'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +test('classifies test, production and other files', () => { + const cases = { + 'packages/core/src/a.test.ts': 'test', + 'packages/cli/src/ui/App.test.tsx': 'test', + 'packages/core/src/a.spec.ts': 'test', + 'packages/core/src/tools/shell.test-helper.ts': 'test', + 'packages/core/src/x/case.fixtures.json': 'test', + 'packages/core/src/utils/testUtils.ts': 'test', + 'packages/core/test-setup.ts': 'test', + 'packages/core/src/__tests__/a.ts': 'test', + 'packages/cli/src/__snapshots__/App.test.tsx.snap': 'test', + 'packages/core/src/test-utils/mock-tool.ts': 'test', + 'packages/cli/src/commands/review/lib/test-utils.ts': 'test', + 'packages/core/src/lsp/__e2e__/lsp-e2e-test.ts': 'test', + 'packages/web-shell/client/e2e/utils/mockDaemon.ts': 'test', + 'packages/web-shell/client/test/setup.ts': 'test', + 'packages/core/src/core/client.ts': 'production', + 'packages/web-shell/client/src/App.tsx': 'production', + 'packages/cli/src/index.mjs': 'production', + 'packages/core/src/config/settings.schema.ts': 'other', + 'packages/core/src/generated/git-commit.ts': 'other', + 'packages/core/src/x.generated.ts': 'other', + 'packages/core/package.json': 'other', + 'packages/web-shell/client/src/app.css': 'other', + 'packages/core/README.md': 'other', + }; + for (const [path, kind] of Object.entries(cases)) { + assert.equal(classify(path), kind, path); + } +}); + +test('counts only core, cli and web-shell', () => { + assert.equal(packageOf('packages/core/src/a.ts'), 'core'); + assert.equal(packageOf('packages/web-shell/client/src/a.ts'), 'web-shell'); + assert.equal(packageOf('packages/sdk-typescript/src/a.ts'), null); + assert.equal(packageOf('scripts/build.js'), null); +}); + +test('tallies numstat per package, skipping binary and other files', () => { + const numstat = [ + '120\t20\tpackages/core/src/a.test.ts', + '30\t5\tpackages/core/src/a.ts', + '-\t-\tpackages/cli/assets/logo.png', + '40\t0\tpackages/cli/src/b.test.tsx', + '9\t1\tpackages/cli/package.json', + '7\t2\tpackages/sdk-typescript/src/c.test.ts', + '', + ].join('\n'); + const entries = parseNumstat(numstat); + assert.equal(entries.length, 5); + assert.deepEqual(tally(entries), { + byPackage: { + core: { + test: { added: 120, deleted: 20 }, + production: { added: 30, deleted: 5 }, + }, + cli: { + test: { added: 40, deleted: 0 }, + production: { added: 0, deleted: 0 }, + }, + 'web-shell': { + test: { added: 0, deleted: 0 }, + production: { added: 0, deleted: 0 }, + }, + }, + total: { + test: { added: 160, deleted: 20 }, + production: { added: 30, deleted: 5 }, + }, + }); +}); + +test('flags at the threshold of net test lines, not below', () => { + const withNet = (n) => + tally([ + { added: n + 100, deleted: 100, path: 'packages/core/src/a.test.ts' }, + ]); + assert.equal(FLAG_THRESHOLD, 2000); + + const below = withNet(FLAG_THRESHOLD - 1); + assert.equal(renderAnnotation(below), null); + assert.doesNotMatch(renderSummary(below), /net test lines, at or above/); + + const at = withNet(FLAG_THRESHOLD); + assert.match( + renderAnnotation(at), + /^::warning title=Test growth::This PR adds 2,000 net test lines/, + ); + assert.match(renderSummary(at), /\| core \| \+2,100 \/ −100 \(net \+2,000\)/); + assert.match( + renderSummary(at), + /2,000 net test lines, at or above the 2,000-line flag/, + ); +}); + +test('measures from the merge base, fetching it only when missing', async () => { + const [base, head, mergeBase] = ['b', 'h', 'm'].map((c) => c.repeat(40)); + const run = async ({ present, partialFails = false }) => { + const calls = []; + const result = await measure({ + repo: 'QwenLM/qwen-code', + baseSha: base, + headSha: head, + fetchJson: async (path) => { + assert.equal( + path, + `/repos/QwenLM/qwen-code/compare/${base}...${head}?per_page=1`, + ); + return { merge_base_commit: { sha: mergeBase } }; + }, + runGit: async (...args) => { + calls.push(args.join(' ')); + if (args[0] === 'cat-file' && !present) throw new Error('missing'); + if (partialFails && args.includes('--filter=blob:none')) { + throw new Error('no partial clone'); + } + // The diff must start at the merge base, not the base tip. + if (args.includes('diff')) return '3\t1\tpackages/core/src/a.test.ts\n'; + return ''; + }, + }); + assert.deepEqual(result.tallied.total.test, { added: 3, deleted: 1 }); + return calls; + }; + const PARTIAL = + '-c extensions.partialClone=origin -c remote.origin.promisor=true'; + const DIFF = `diff --numstat --no-renames ${mergeBase} ${head} -- packages/core packages/cli packages/web-shell`; + + assert.deepEqual(await run({ present: true }), [ + `cat-file -e ${mergeBase}^{commit}`, + DIFF, + ]); + assert.deepEqual(await run({ present: false }), [ + `cat-file -e ${mergeBase}^{commit}`, + `${PARTIAL} fetch --no-tags --depth=1 --filter=blob:none origin ${mergeBase}`, + `${PARTIAL} ${DIFF}`, + 'config --unset remote.origin.partialclonefilter', + ]); + assert.deepEqual( + (await run({ present: false, partialFails: true })).slice(2), + [`fetch --no-tags --depth=1 origin ${mergeBase}`, DIFF], + ); +}); + +test('ci.yml runs the check in lint_and_static on pull requests', () => { + const ci = readFileSync(join(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + const jobStart = ci.indexOf('\n lint_and_static:'); + const jobEnd = ci.indexOf('\n test_macos:', jobStart); + assert.ok(jobStart > 0 && jobEnd > jobStart, 'lint_and_static job not found'); + const job = ci.slice(jobStart, jobEnd); + + const step = job.indexOf("- name: 'Report test growth'"); + assert.ok(step > 0, 'test growth step missing'); + const stepBlock = job.slice(step, job.indexOf('\n - name:', step + 1)); + for (const expected of [ + "github.event_name == 'pull_request'", + "skip_ci != 'true'", + 'github.event.pull_request.base.sha', + 'github.event.pull_request.head.sha', + 'node .github/scripts/check-test-growth.mjs', + ]) { + assert.ok(stepBlock.includes(expected), `step must include ${expected}`); + } + assert.ok( + step < job.indexOf("- name: 'Install dependencies'"), + 'the check needs no dependencies and must run before the install', + ); + + for (const list of ['HELPER_TESTS_DEP_FREE', 'HELPER_TESTS']) { + const line = ci.split('\n').find((l) => l.trim().startsWith(`${list}:`)); + assert.ok( + line?.includes('.github/scripts/check-test-growth.test.mjs'), + `${list} must run this test`, + ); + } +}); + +test('API errors never carry the token', async () => { + const failing = async () => { + throw Object.assign( + new Error('Command failed: curl -H Authorization: Bearer secret-token'), + { + code: 22, + stderr: 'curl: (22) The requested URL returned error: 401\n', + }, + ); + }; + await assert.rejects( + curlJson('/repos/o/r/compare/a...b', 'secret-token', failing), + (error) => { + assert.doesNotMatch(error.message, /secret-token/); + assert.match(error.message, /curl exit 22\): curl: \(22\).*401$/); + return true; + }, + ); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ddfaa288f..9eacc72167a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,8 +87,8 @@ env: # re-runs every suite after `npm ci`, and a push to main always classifies # `full` (classify_pr computes a profile only for pull_request), so what # the fast lane skips is still re-checked post-merge. - HELPER_TESTS_DEP_FREE: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs' - HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/assign-pr-owner.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/ci-disk-pressure.test.mjs .github/scripts/e2e-build.test.mjs integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/ecs-runner/review-scratch-cleanup.test.mjs' + HELPER_TESTS_DEP_FREE: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/check-test-growth.test.mjs' + HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/cap-release-notes.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/classify-platform-sensitivity.test.mjs .github/scripts/ci/classify-pr-profile.test.mjs .github/scripts/upsert-bot-comment.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/dsw-swe-verified/make-terminal-bench-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/update-ecs-runner-qwen-workflow.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/serve-ab-drive.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/assign-issue-owner.test.mjs .github/scripts/auto-minimize-spam.test.mjs .github/scripts/ci-runner-routing.test.mjs .github/scripts/autofix-status-heartbeat.test.mjs .github/scripts/assign-pr-owner.test.mjs .github/scripts/check-disk-floor.test.mjs .github/scripts/check-lint-gate-freshness.test.mjs .github/scripts/ci-disk-pressure.test.mjs .github/scripts/e2e-build.test.mjs integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.test.mjs .github/scripts/review-runner-schedule.test.mjs .github/scripts/ecs-runner/qwen-docker-cleanup.test.mjs .github/scripts/ecs-runner/review-scratch-cleanup.test.mjs .github/scripts/check-test-growth.test.mjs' # The growth ratchet and its vitest mirror compare each workflow against # the PR's base commit to tell "this PR grew the file" apart from "the # baseline went stale on main" (#9904). Wired once here so every lane @@ -1097,6 +1097,17 @@ jobs: HEAD_SHA: '${{ github.event.pull_request.head.sha }}' run: 'node .github/scripts/check-lint-gate-freshness.mjs' + # Advisory test-growth summary and flag; see the script header. + - name: 'Report test growth' + if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && github.event_name == 'pull_request' }}" + continue-on-error: true + timeout-minutes: 5 + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + BASE_SHA: '${{ github.event.pull_request.base.sha }}' + HEAD_SHA: '${{ github.event.pull_request.head.sha }}' + run: 'node .github/scripts/check-test-growth.mjs' + # Avoid setup-node downloads on ECS, where nodejs.org may be unreachable # through the egress proxy; reuse the machine's Node instead. - name: 'Set up Node.js 22.x (hosted)'