From a14afdab51e8902d92011048c95e61a4d52213d4 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Mon, 9 Nov 2026 16:20:01 -0800 Subject: [PATCH] chore(release): apply final v0.7.12 policy correction Exact ports from main: - active-release.mjs and active-release.test.mjs from 73c1a49170c9b0e0ddf39df2d197eb2e6cdce19c (#10923) - touched-components.test.mjs from c7558e8d3af (#10634) This is the second and final corrective policy sync under AST-017 FR61 and DEC-32. --- .github/scripts/touched-components.test.mjs | 4 +- scripts/release/active-release.mjs | 10 +- scripts/release/active-release.test.mjs | 636 ++++++++++++++++++++ 3 files changed, 646 insertions(+), 4 deletions(-) diff --git a/.github/scripts/touched-components.test.mjs b/.github/scripts/touched-components.test.mjs index bd7e00cf04198..001460d023a26 100644 --- a/.github/scripts/touched-components.test.mjs +++ b/.github/scripts/touched-components.test.mjs @@ -754,7 +754,7 @@ describe('pr-component-a11y-contracts routing', () => { it('fails, never passes, when the scope step cannot list the changed files', () => { const guard = contractStep('Require the changed-file list'); expect(guard.if).toBe( - "steps.scope.outputs.applies == 'true' && steps.scope.outputs.changed_files_path == ''", + "steps.scope.outputs.applies == 'true' && steps.scope.outputs.changed_files_path == '' && steps.scope.outputs.decided != 'false'", ); expect(guard.run).toContain('exit 1'); expect(guard['continue-on-error']).toBeUndefined(); @@ -833,7 +833,7 @@ describe('pr-component-a11y-contracts routing', () => { // Every changed path, not only the changed specs: a change in a // component folder owns that folder's specs. expect(runStep.env.FILES_PATH).toBe( - '${{ steps.scope.outputs.changed_files_path }}', + '${{ steps.scope.outputs.changed_files_path || steps.everything.outputs.path }}', ); // A wide change still runs the touched components' own specs, then the // specs of the folders that render them (FR12). diff --git a/scripts/release/active-release.mjs b/scripts/release/active-release.mjs index 02d05a4b35967..0aa91e241e907 100644 --- a/scripts/release/active-release.mjs +++ b/scripts/release/active-release.mjs @@ -367,8 +367,14 @@ function validateReleaseState({ return errors; } +const GIT_MAX_BUFFER = 64 * 1024 * 1024; + function gitRaw(root, args) { - return execFileSync('git', args, {cwd: root, encoding: 'utf8'}); + return execFileSync('git', args, { + cwd: root, + encoding: 'utf8', + maxBuffer: GIT_MAX_BUFFER, + }); } function git(root, args) { @@ -1068,7 +1074,7 @@ const RELEASE_POLICY_CARRY_PATTERNS = [ /^scripts\/release\/active-release(?:\.test)?\.mjs$/, /^scripts\/release\/release-paths\.mjs$/, /^\.github\/actions\/test-scope\/action\.yml$/, - /^\.github\/scripts\/test-scope-action\.test\.mjs$/, + /^\.github\/scripts\/(?:test-scope-action|touched-components)\.test\.mjs$/, /^\.github\/workflows\/ci\.yml$/, /^docs\/release\.md$/, /^docs\/specs\/AST-(?:017|030)\/spec\.md$/, diff --git a/scripts/release/active-release.test.mjs b/scripts/release/active-release.test.mjs index cc60bedcc5c39..33dc9a6034838 100644 --- a/scripts/release/active-release.test.mjs +++ b/scripts/release/active-release.test.mjs @@ -8,6 +8,7 @@ * @position focused safety suite for the release-branch CI contract */ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -23,11 +24,14 @@ import { buildMarker, buildPlan, canaryEligibility, + changedStatementsFromDiff, declaredVersionAtRef, + isReleaseTestRepairPath, latestStableVersion, mergeBackEntries, nextPlannedVersion, validateReleaseDiff, + validateReleaseTestRepair, validateHistoricalReleaseMergeBack, validateReleaseMergeBack, validateReleaseState, @@ -452,6 +456,638 @@ describe('active release branch authority', () => { ).toEqual([]); }); + it('admits one post-bump test repair whose patch already merged on main', () => { + const patchId = 'a'.repeat(40); + const changeDigest = 'c'.repeat(64); + const sourceCommit = 'b'.repeat(40); + const valid = { + entries: [ + 'M\tpackages/core/src/BottomSheet/__tests__/BottomSheetRelease.a11y.chromium.spec.ts', + ], + changedLines: ['await mouseDrag(page, {duration: 50});'], + commitCount: 1, + mergeCommitCount: 0, + patchId, + changeDigest, + mainPatches: new Map([ + [patchId, [{commit: sourceCommit, changeDigest, presentAtMain: true}]], + ]), + baseChangesets: new Map(), + }; + expect(isReleaseTestRepairPath(valid.entries[0].split('\t')[1])).toBe(true); + expect( + isReleaseTestRepairPath( + 'packages/cli/assets/codemods/transforms/v0.7.12/__tests__/remove-old-api.test.mjs', + ), + ).toBe(false); + expect( + isReleaseTestRepairPath( + 'packages/cli/assets/codemods/__tests__/registry.test.mjs', + ), + ).toBe(false); + expect(validateReleaseTestRepair(valid)).toEqual({ + errors: [], + sourceCommit, + }); + }); + + it('fails closed for any broader or unproven release test repair', () => { + const patchId = 'a'.repeat(40); + const changeDigest = 'c'.repeat(64); + const sourceCommit = 'b'.repeat(40); + const repair = overrides => + validateReleaseTestRepair({ + entries: ['M\tinternal/interaction-harness/runner.ts'], + changedLines: ['const release = event.timeStamp;'], + commitCount: 1, + mergeCommitCount: 0, + patchId, + changeDigest, + mainPatches: new Map([ + [ + patchId, + [{commit: sourceCommit, changeDigest, presentAtMain: true}], + ], + ]), + baseChangesets: new Map(), + ...overrides, + }).errors; + + expect( + repair({ + entries: [ + 'R100\tpackages/core/src/BottomSheet/__tests__/BottomSheetScrimClock.a11y.browser.spec.ts\tpackages/core/src/BottomSheet/__tests__/BottomSheetScrimClock.a11y.browser.spec.ts.disabled', + ], + }), + ).toContain( + 'release test repair may only modify an existing test: R100\tpackages/core/src/BottomSheet/__tests__/BottomSheetScrimClock.a11y.browser.spec.ts\tpackages/core/src/BottomSheet/__tests__/BottomSheetScrimClock.a11y.browser.spec.ts.disabled', + ); + expect( + repair({entries: ['A\tpackages/core/src/Button/Button.test.tsx']}), + ).toContain( + 'release test repair may only modify an existing test: A\tpackages/core/src/Button/Button.test.tsx', + ); + expect( + repair({entries: ['M\tpackages/core/src/Button/Button.tsx']}), + ).toContain( + 'release test repair contains a non-test path: packages/core/src/Button/Button.tsx', + ); + expect( + repair({ + entries: ['M\tpackages/core/src/Button/__snapshots__/Button.snap'], + }), + ).toContain( + 'release test repair contains a non-test path: packages/core/src/Button/__snapshots__/Button.snap', + ); + expect( + repair({ + entries: ['M\tapps/docsite/e2e/theme-showcase.spec.ts'], + }), + ).toContain( + 'release test repair contains a non-test path: apps/docsite/e2e/theme-showcase.spec.ts', + ); + expect( + repair({ + entries: ['M\tapps/docsite/src/__tests__/routing.test.ts'], + }), + ).toContain( + 'release test repair contains a non-test path: apps/docsite/src/__tests__/routing.test.ts', + ); + for (const line of [ + 'expect(dialog).toBeHidden();', + '.toBeHidden({visible: false});', + 'expectNoA11yViolations(page);', + 'test.skip(true, "flaky");', + 'test.fail();', + 'test.slow();', + 'skip();', + 'ctx.fixme();', + 'testInfo.skip();', + 'test.info().fail();', + 'it.skipIf(process.platform === "darwin")();', + 'describe.runIf(false)();', + 'const timeoutMs = 2_000;', + 'retries: 3,', + 'axe.disableRules(["color-contrast"]);', + 'return;', + 'expect(tree).toMatchSnapshot();', + ]) { + expect(repair({changedLines: [line]})).toContain( + `release test repair changes an assertion, test control, timeout, retry, or snapshot: ${line}`, + ); + } + const statementCases = [ + { + diff: `diff --git a/test.mjs b/test.mjs +--- a/test.mjs ++++ b/test.mjs +@@ -1,6 +1,6 @@ + before(); + await expect.poll(() => count).toBeGreaterThan( +- 0, ++ 1, + ); + after();`, + expected: + 'await expect.poll(() => count).toBeGreaterThan(\n 1,\n);', + }, + { + diff: `diff --git a/test.mjs b/test.mjs +--- a/test.mjs ++++ b/test.mjs +@@ -1,6 +1,6 @@ + before(); + await expect(result).toEqual({ +- value: 1, ++ value: 2, + }); + after();`, + expected: 'await expect(result).toEqual({\n value: 2,\n});', + }, + { + diff: `diff --git a/test.mjs b/test.mjs +--- a/test.mjs ++++ b/test.mjs +@@ -1,7 +1,7 @@ + before(); + await expect.poll(() => { +- return ready; ++ return settled; + }).toBe(true); + after();`, + expected: + 'await expect.poll(() => {\n return settled;\n}).toBe(true);', + }, + { + diff: `diff --git a/test.mjs b/test.mjs +--- a/test.mjs ++++ b/test.mjs +@@ -84,5 +84,5 @@ + children: [ + {type: 'paragraph'}, + ], +- ordered: false, ++ ordered: true, + });`, + expected: '', + }, + { + diff: `diff --git a/test.mjs b/test.mjs +--- a/test.mjs ++++ b/test.mjs +@@ -493,5 +493,5 @@ + brand: { + light: { +- accent: neutralPalettes.green.light[25], ++ accent: neutralPalettes.green.light[30], + }, + },`, + expected: '', + }, + ]; + for (const {diff, expected} of statementCases) { + const changedStatements = changedStatementsFromDiff(diff); + expect(changedStatements).toContain(expected); + expect(repair({changedLines: undefined, changedStatements})).toContain( + `release test repair changes an assertion, test control, timeout, retry, or snapshot: ${expected}`, + ); + } + const portStatements = changedStatementsFromDiff(`diff --git a/test.mjs b/test.mjs +--- a/test.mjs ++++ b/test.mjs +@@ -1,7 +1,7 @@ + /** + * @file BottomSheetRelease.a11y.chromium.spec.ts +- * @input Built BottomSheet stories and real Chromium touch input (CDP), each +- * event with its own timestamp ++ * @input Built BottomSheet stories and real Chromium touch and mouse input ++ * (CDP), each event with its own timestamp + * @output Browser evidence for the touch release. + */ +@@ -20,5 +20,8 @@ + setup(); ++const cdp = await page.context().newCDPSession(page); ++// 150 px in 50 ms is 3,000 px/s, so the mouse release projects past the ++// dismiss line even when event delivery is slow. ++await mouseDrag(cdp, sheet, 150, 50); + cleanup();`); + expect(portStatements).not.toContain(''); + + const multiStatementChange = changedStatementsFromDiff(`diff --git a/test.mjs b/test.mjs +--- a/test.mjs ++++ b/test.mjs +@@ -1 +1 @@ +-safe(); expect(value).toBe(true); ++safe(); expect(value).toBe(false);`); + expect(multiStatementChange).toContain('expect(value).toBe(false);'); + expect( + repair({changedLines: undefined, changedStatements: multiStatementChange}), + ).toContain( + 'release test repair changes an assertion, test control, timeout, retry, or snapshot: expect(value).toBe(false);', + ); + + expect( + changedStatementsFromDiff(`diff --git a/test.mjs b/test.mjs +--- a/test.mjs ++++ b/test.mjs +@@ -1 +1 @@ +-// @vitest-environment node ++// @vitest-environment jsdom`), + ).toContain(''); + + expect(repair({commitCount: 2})).toContain( + 'release test repair must contain exactly one non-merge commit', + ); + expect(repair({mergeCommitCount: 1})).toContain( + 'release test repair must contain exactly one non-merge commit', + ); + expect(repair({mainPatches: new Map()})).toContain( + 'release test repair does not exactly match a non-merge commit still present on main', + ); + expect(repair({changeDigest: 'd'.repeat(64)})).toContain( + 'release test repair does not exactly match a non-merge commit still present on main', + ); + expect( + repair({ + mainPatches: new Map([ + [ + patchId, + [ + { + commit: sourceCommit, + changeDigest, + presentAtMain: false, + }, + ], + ], + ]), + }), + ).toContain( + 'release test repair does not exactly match a non-merge commit still present on main', + ); + expect( + repair({ + baseChangesets: new Map([['.changeset/frozen.md', 'c'.repeat(64)]]), + }), + ).toContain( + 'release test repair requires the generated version bump to be merged first', + ); + }); + + it('proves the real git patch against current main before the CLI admits it', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'astryx-release-test-repair-'), + ); + roots.push(root); + const testFile = + 'packages/core/src/BottomSheet/__tests__/BottomSheetRelease.a11y.chromium.spec.ts'; + const before = + 'const ready = true;\nawait page.mouse.move(0, 150);\n\n'; + const after = 'const ready = true;\nawait mouseDrag(page);\n\n'; + const {cut} = createRepository(root, [ + { + ref: 'refs/heads/main', + name: 'cut', + snapshot: true, + files: { + '.changeset/README.md': '# Changesets\n', + 'pnpm-workspace.yaml': "packages:\n - 'packages/*'\n", + 'packages/core/package.json': JSON.stringify({ + name: '@astryxdesign/core', + version: '0.6.5', + }), + [testFile]: before, + }, + }, + ]); + const plan = { + schemaVersion: 2, + version: '0.6.5', + branch: BRANCH, + cutSha: cut, + changesets: [], + }; + const marker = buildMarker(plan); + const { + mainRepair, + largeMain, + mixedMain, + mainRevert, + releaseBase, + releaseRepair, + whitespaceRepair, + } = importCommits(root, [ + { + ref: 'refs/heads/main', + name: 'mainRepair', + parent: cut, + files: {[testFile]: after}, + }, + { + ref: 'refs/heads/main', + name: 'largeMain', + parent: 'mainRepair', + files: { + [testFile]: `${after}// later main context\n`, + 'large-unrelated.txt': 'x'.repeat(2 * 1024 * 1024), + }, + }, + { + ref: 'refs/heads/mixed-main', + name: 'mixedMain', + parent: cut, + files: { + [testFile]: after, + 'packages/core/src/Button/Button.tsx': + 'export const productChange = true;\n', + }, + }, + { + ref: `refs/heads/${BRANCH}`, + name: 'releaseBase', + parent: cut, + files: { + '.release/active.json': `${JSON.stringify(marker)}\n`, + '.release/plan.json': `${JSON.stringify(plan)}\n`, + }, + }, + { + ref: 'refs/heads/repair', + name: 'releaseRepair', + parent: 'releaseBase', + files: {[testFile]: after}, + }, + { + ref: 'refs/heads/whitespace-repair', + name: 'whitespaceRepair', + parent: 'releaseBase', + files: { + [testFile]: + 'const ready = true;\nawait mouse Drag(page);\n\n', + }, + }, + { + ref: 'refs/heads/reverted-main', + name: 'mainRevert', + parent: 'mainRepair', + files: {[testFile]: before}, + }, + ]); + const validator = path.join(import.meta.dirname, 'active-release.mjs'); + const run = (head, mainRef) => + execFileSync( + process.execPath, + [ + validator, + 'validate-diff', + '--root', + root, + '--base', + releaseBase, + '--head', + head, + '--main-ref', + mainRef, + ], + {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}, + ); + + expect(JSON.parse(run(releaseRepair, largeMain))).toMatchObject({ + kind: 'test-repair', + sourceCommit: mainRepair, + files: 1, + }); + for (const [head, mainRef] of [ + [whitespaceRepair, mainRepair], + [releaseRepair, mainRevert], + [releaseRepair, mixedMain], + ]) { + try { + run(head, mainRef); + throw new Error('expected validation to fail'); + } catch (error) { + expect(error.stderr).toContain( + 'does not exactly match a non-merge commit still present on main', + ); + } + } + }); + + it('admits only an exact reviewed policy bundle onto a predating release branch', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'astryx-release-policy-carry-'), + ); + roots.push(root); + const validatorFile = 'scripts/release/active-release.mjs'; + const activeTestFile = 'scripts/release/active-release.test.mjs'; + const touchedTestFile = '.github/scripts/touched-components.test.mjs'; + const workflowFile = '.github/workflows/ci.yml'; + const manifestPath = 'scripts/release/policy-carry/v0.6.5.json'; + const patchPath = 'scripts/release/policy-carry/v0.6.5.diff'; + const digest = value => + crypto.createHash('sha256').update(value).digest('hex'); + const {cut} = createRepository(root, [ + { + ref: 'refs/heads/main', + name: 'cut', + snapshot: true, + files: { + [validatorFile]: 'main generated only\n', + [activeTestFile]: 'main validator tests\n', + [touchedTestFile]: 'main workflow contract tests\n', + [workflowFile]: 'main trusted base\n', + 'packages/core/src/Button/Button.tsx': 'export const Button = 1;\n', + }, + }, + ]); + const policyFiles = { + [validatorFile]: 'generated or proven test repair\n', + [workflowFile]: 'trusted base plus current main\n', + }; + const {releaseBase, policyCarry, wrongContent, productCarry} = + importCommits(root, [ + { + ref: `refs/heads/${BRANCH}`, + name: 'releaseBase', + parent: cut, + files: { + [validatorFile]: 'release generated only\n', + [workflowFile]: 'release trusted base\n', + }, + }, + { + ref: 'refs/heads/policy-carry', + name: 'policyCarry', + parent: 'releaseBase', + files: policyFiles, + }, + { + ref: 'refs/heads/wrong-content', + name: 'wrongContent', + parent: 'releaseBase', + files: { + ...policyFiles, + [workflowFile]: 'almost trusted base plus current main\n', + }, + }, + { + ref: 'refs/heads/product-carry', + name: 'productCarry', + parent: 'releaseBase', + files: { + ...policyFiles, + 'packages/core/src/Button/Button.tsx': 'export const Button = 2;\n', + }, + }, + ]); + const patch = execFileSync( + 'git', + ['diff', '--binary', releaseBase, policyCarry], + {cwd: root, encoding: 'utf8'}, + ); + const targets = Object.fromEntries( + Object.keys(policyFiles) + .sort() + .map(file => [ + file, + digest( + execFileSync('git', ['show', `${policyCarry}:${file}`], { + cwd: root, + }), + ), + ]), + ); + const manifest = { + schemaVersion: 1, + releaseBranch: BRANCH, + base: releaseBase, + patch: patchPath, + patchSha256: digest(patch), + targets, + }; + const {mainPolicy} = importCommits(root, [ + { + ref: 'refs/heads/main', + name: 'mainPolicy', + parent: cut, + files: { + [manifestPath]: `${JSON.stringify(manifest)}\n`, + [patchPath]: patch, + }, + }, + ]); + const validator = path.join(import.meta.dirname, 'active-release.mjs'); + const run = (head, {base = releaseBase, source = mainPolicy} = {}) => + execFileSync( + process.execPath, + [ + validator, + 'validate-policy-carry', + '--root', + root, + '--base', + base, + '--head', + head, + '--source', + source, + '--main-ref', + source, + '--manifest', + manifestPath, + '--release-branch', + BRANCH, + ], + {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}, + ); + + expect(JSON.parse(run(policyCarry))).toMatchObject({ + kind: 'policy-carry', + source: mainPolicy, + manifest: manifestPath, + files: 2, + }); + + const secondPolicyFiles = { + [validatorFile]: 'bounded validator output\n', + [activeTestFile]: 'validator regression tests\n', + [touchedTestFile]: 'fallback workflow contract tests\n', + }; + const {secondCarry} = importCommits(root, [ + { + ref: 'refs/heads/second-policy-carry', + name: 'secondCarry', + parent: policyCarry, + files: secondPolicyFiles, + }, + ]); + const secondPatch = execFileSync( + 'git', + ['diff', '--binary', policyCarry, secondCarry], + {cwd: root, encoding: 'utf8'}, + ); + const secondManifest = { + schemaVersion: 1, + releaseBranch: BRANCH, + base: policyCarry, + patch: patchPath, + patchSha256: digest(secondPatch), + targets: Object.fromEntries( + Object.keys(secondPolicyFiles) + .sort() + .map(file => [ + file, + digest( + execFileSync('git', ['show', `${secondCarry}:${file}`], { + cwd: root, + }), + ), + ]), + ), + }; + const {mainSecondPolicy} = importCommits(root, [ + { + ref: 'refs/heads/main', + name: 'mainSecondPolicy', + parent: mainPolicy, + files: { + [manifestPath]: `${JSON.stringify(secondManifest)}\n`, + [patchPath]: secondPatch, + }, + }, + ]); + expect( + JSON.parse( + run(secondCarry, { + base: policyCarry, + source: mainSecondPolicy, + }), + ), + ).toMatchObject({ + kind: 'policy-carry', + source: mainSecondPolicy, + manifest: manifestPath, + files: 3, + }); + + try { + run(wrongContent); + throw new Error('expected validation to fail'); + } catch (error) { + expect(error.stderr).toContain( + `release policy carry file does not match its reviewed bundle: ${workflowFile}`, + ); + } + try { + run(productCarry); + throw new Error('expected validation to fail'); + } catch (error) { + expect(error.stderr).toContain( + 'release policy carry contains an unauthorized path: packages/core/src/Button/Button.tsx', + ); + } + }); + it('binds generated-only deletion to the frozen plan paths and hashes', () => { const digest = value => value.repeat(64).slice(0, 64); const plan = {