From 74f21d5913113586198fffcc0d48432f3aa66ee9 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 29 Oct 2026 18:37:40 -0700 Subject: [PATCH 1/6] ci: fold fixture-contrast into test-build The canonical setup compositions and the guest fixture contrast need a production browser render, and test-build already installs Playwright on the same runner under the same scope condition. Running them there drops a job from every pull request without changing what runs or when. --- .github/scripts/ci-tooling-routing.test.mjs | 11 ++++++++++- .github/workflows/ci.yml | 22 +++------------------ 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/scripts/ci-tooling-routing.test.mjs b/.github/scripts/ci-tooling-routing.test.mjs index ec002723bc47b..4235dd16c2620 100644 --- a/.github/scripts/ci-tooling-routing.test.mjs +++ b/.github/scripts/ci-tooling-routing.test.mjs @@ -116,7 +116,16 @@ describe('Node-tooling CI routing', () => { 'node .github/scripts/theme-layer-cascade.js', 'ASTRYX_STYLEX_BROWSER=1 vitest run scripts/stylex-module-resolution.browser.test.mjs --project node --maxWorkers=1', ]); - expect(ci.jobs['fixture-contrast'].if).toContain(TOOLING_FALSE); + // The vibe-tests fixtures share test-build's Playwright runner, and its + // Node-tooling skip. + expect(ci.jobs['fixture-contrast']).toBeUndefined(); + for (const name of [ + 'Verify canonical setup compositions', + 'Verify guest fixture contrast', + ]) { + expect(step(ci.jobs['test-build'], name)).toBeDefined(); + } + expect(ci.jobs['test-build'].if).toContain(TOOLING_FALSE); // The merge-base size build is heavy build work and stays off the // Node-tooling, docsite, and spec lanes. expect(ci.jobs['size-base'].if).toContain(BROAD_ONLY); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef868ed8e243c..436ca28c2fac4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2034,24 +2034,9 @@ jobs: - name: Run Build package contracts run: pnpm test:build - # Guest token boundaries cross portals and the host light/dark cascade. This - # requires a production browser render; source and jsdom checks cannot prove - # which custom properties paint the portaled surfaces. - fixture-contrast: - needs: [check-scope] - if: github.event_name != 'workflow_dispatch' && needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' - runs-on: 2-core-ubuntu-arm - timeout-minutes: 15 - permissions: - contents: read - steps: - - uses: actions/checkout@v7 - - - uses: ./.github/actions/setup - - - name: Install Playwright - run: npx playwright install chromium - + # The vibe-tests fixtures share this Playwright-equipped runner: the + # canonical setup compositions and the guest fixture contrast need a + # production browser render (registry scope test-theme-e2e). - name: Verify canonical setup compositions env: ASTRYX_CANONICAL_SETUP_BROWSER: '1' @@ -2747,7 +2732,6 @@ jobs: interaction-scope, pr-interaction, test-build, - fixture-contrast, visual-scout, pr-visual, pr-rtl-shard, From bc9ce0e0e2949655b9fc53e2ebf90a7c5491ddc4 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 29 Oct 2026 18:40:39 -0700 Subject: [PATCH 2/6] ci: interaction and native Swift decide their own scope Adds the test-scope action: inside the job that owns the work, it reads the pull request's file list from GitHub and the base commit's trusted classifier, and puts everything in scope when it cannot decide (spec:AST-030 FR3, FR4). The interaction proofs and the native Swift build use it, so the interaction-scope and native-swift-scope jobs, the pr-scope-touches helper and its tests go away. Native Swift keeps one named runner-selection pre-job, native-swift-runner, because its macOS runner must be chosen before the job starts. AST-030's path list names the action instead of the helper. --- .github/actions/test-scope/action.yml | 189 +++++++++++++ .github/scripts/ci-interaction.test.mjs | 223 ++------------- .github/scripts/ci-native-swift.test.mjs | 78 +++--- .github/scripts/ci-scope-pr-diff.test.mjs | 261 ----------------- .github/scripts/ci-test-routing.test.mjs | 28 +- .github/scripts/pr-scope-touches.sh | 48 ---- .github/scripts/pr-scope-touches.test.mjs | 194 ------------- .github/scripts/test-scope-action.test.mjs | 309 +++++++++++++++++++++ .github/workflows/ci.yml | 233 ++++------------ docs/specs/AST-030/spec.md | 2 +- 10 files changed, 629 insertions(+), 936 deletions(-) create mode 100644 .github/actions/test-scope/action.yml delete mode 100644 .github/scripts/ci-scope-pr-diff.test.mjs delete mode 100755 .github/scripts/pr-scope-touches.sh delete mode 100644 .github/scripts/pr-scope-touches.test.mjs create mode 100644 .github/scripts/test-scope-action.test.mjs diff --git a/.github/actions/test-scope/action.yml b/.github/actions/test-scope/action.yml new file mode 100644 index 0000000000000..407f8dfc508a5 --- /dev/null +++ b/.github/actions/test-scope/action.yml @@ -0,0 +1,189 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. + +# Decides, inside the job that owns the work, whether a pull request changes +# anything in that job's scope (spec:AST-030). There is no separate scope job: +# each `test-` job runs this step right after its checkout and skips +# its remaining steps, reporting success, when nothing in scope changed. +# +# - The PR's own change comes from GitHub's file list for the pull request +# (its diff from the merge base), never from main's history since an old +# base, so a long-lived branch does not inherit main's changes (#8812) and +# no history is fetched or deepened (#8826). +# - The classifier (change-scope.cjs and its two helpers) is read from the PR's +# base commit, so a pull request cannot loosen its own classification. +# - Anything this step cannot decide (not a pull request, a missing trusted +# classifier, an API error, an empty file list or one GitHub truncates) +# puts everything in scope (spec:AST-030 FR3, FR4). A classifier that rejects the change (a spec-only PR with a +# Changeset) fails the step, as the old scope job failed its dependents. +# - A release-branch pull request is the generated version bump: spec-only and +# tooling-only, with `release_bump` true. +# +# The job must be checked out first: this is a local action. + +name: Test scope +description: Whether this pull request changes anything in the calling job's scope. + +inputs: + paths: + description: >- + Optional regular expression over changed file paths (old and new names + of a rename). When given, `touches` says whether any changed path + matches; when empty, `touches` is true. + required: false + default: '' + outside-pull-request: + description: >- + `applies` outside a pull request (a merge-queue entry): `run` (the + default) or `skip`, for evidence that already ran on the pull request. + required: false + default: run + requires-head-file: + description: >- + Optional path the work needs at the PR head. A head cut before that file + existed, whose diff does not touch it, does not apply (with a notice). + required: false + default: '' + +outputs: + spec_only: + description: The PR changes only spec records. + value: ${{ steps.scope.outputs.spec_only }} + docsite_only: + description: The PR changes only the docsite. + value: ${{ steps.scope.outputs.docsite_only }} + tooling_only: + description: The PR changes only Node tooling. + value: ${{ steps.scope.outputs.tooling_only }} + full_suite: + description: The import graph cannot scope this change; run every test. + value: ${{ steps.scope.outputs.full_suite }} + release_bump: + description: The PR targets a release branch (the generated version bump). + value: ${{ steps.scope.outputs.release_bump }} + touches: + description: A changed path matches `paths` (true when `paths` is empty or undecidable). + value: ${{ steps.scope.outputs.touches }} + applies: + description: >- + The job's work applies: not a specialized lane (spec, docsite or tooling + only), `touches` holds, and any `requires-head-file` is present. + value: ${{ steps.scope.outputs.applies }} + +runs: + using: composite + steps: + - id: scope + uses: actions/github-script@v9 + env: + SCOPE_PATHS: ${{ inputs.paths }} + SCOPE_OUTSIDE_PR: ${{ inputs.outside-pull-request }} + SCOPE_HEAD_FILE: ${{ inputs.requires-head-file }} + with: + retries: 3 + script: | + const fs = require('node:fs'); + const os = require('node:os'); + const path = require('node:path'); + const {execFileSync} = require('node:child_process'); + + const everything = { + spec_only: 'false', + docsite_only: 'false', + tooling_only: 'false', + full_suite: 'true', + release_bump: 'false', + touches: 'true', + applies: 'true', + }; + const report = values => { + for (const [key, value] of Object.entries(values)) core.setOutput(key, value); + core.info(Object.entries(values).map(([k, v]) => `${k}=${v}`).join(' ')); + }; + + if (context.eventName !== 'pull_request') { + core.info(`${context.eventName}: not a pull request, so everything is in scope.`); + report({...everything, applies: process.env.SCOPE_OUTSIDE_PR === 'skip' ? 'false' : 'true'}); + return; + } + const pr = context.payload.pull_request; + if (pr.base.ref.startsWith('release/v')) { + core.info('Release-branch pull request: the generated version bump.'); + report({ + ...everything, + spec_only: 'true', + tooling_only: 'true', + release_bump: 'true', + touches: 'false', + applies: 'false', + }); + return; + } + + let files; + let root; + try { + files = await github.paginate(github.rest.pulls.listFiles, { + ...context.repo, + pull_number: pr.number, + per_page: 100, + }); + // GitHub lists at most 3000 files for a pull request. + if (files.length >= 3000) throw new Error('GitHub truncated the file list at 3000'); + if (files.length === 0) throw new Error('GitHub listed no changed files'); + root = fs.mkdtempSync(path.join(os.tmpdir(), 'test-scope-')); + for (const file of [ + '.github/scripts/change-scope.cjs', + '.github/scripts/knowledge-paths.cjs', + 'scripts/component-packages.cjs', + ]) { + const {data} = await github.rest.repos.getContent({...context.repo, path: file, ref: pr.base.sha}); + fs.mkdirSync(path.join(root, path.dirname(file)), {recursive: true}); + fs.writeFileSync(path.join(root, file), Buffer.from(data.content, 'base64')); + } + } catch (error) { + core.warning(`Scope could not be decided (${error.message}); everything is in scope.`); + report(everything); + return; + } + + const STATUS = {added: 'A', removed: 'D', modified: 'M', changed: 'M', copied: 'C', unchanged: 'M'}; + const nameStatus = files + .map(f => f.status === 'renamed' + ? `R100\t${f.previous_filename}\t${f.filename}` + : `${STATUS[f.status] ?? 'M'}\t${f.filename}`) + .join('\n'); + const outputFile = path.join(root, 'classification'); + fs.writeFileSync(outputFile, ''); + try { + execFileSync('node', [path.join(root, '.github/scripts/change-scope.cjs'), '--github-output'], { + input: `${nameStatus}\n`, + env: {...process.env, GITHUB_OUTPUT: outputFile}, + stdio: ['pipe', 'inherit', 'inherit'], + }); + } catch (error) { + core.setFailed(`The trusted classifier rejected this change: ${error.message}`); + return; + } + const classified = Object.fromEntries( + fs.readFileSync(outputFile, 'utf8').split('\n').filter(Boolean).map(line => line.split('=')), + ); + + let touches = 'true'; + if (process.env.SCOPE_PATHS) { + const pattern = new RegExp(process.env.SCOPE_PATHS); + touches = String(files.some(f => pattern.test(f.filename) || (f.previous_filename != null && pattern.test(f.previous_filename)))); + } + const specialized = ['spec_only', 'docsite_only', 'tooling_only'].some(key => classified[key] === 'true'); + let applies = String(!specialized && touches === 'true'); + const headFile = process.env.SCOPE_HEAD_FILE; + if (applies === 'true' && headFile && !files.some(f => f.filename === headFile || f.previous_filename === headFile)) { + try { + await github.rest.repos.getContent({...context.repo, path: headFile, ref: pr.head.sha}); + } catch (error) { + if (error.status === 404) { + core.notice(`This pull request's head has no ${headFile}; merge main to run this check.`); + applies = 'false'; + } + } + } + report({...everything, ...classified, release_bump: 'false', touches, applies}); diff --git a/.github/scripts/ci-interaction.test.mjs b/.github/scripts/ci-interaction.test.mjs index 016f024177577..c06f20827ea2c 100644 --- a/.github/scripts/ci-interaction.test.mjs +++ b/.github/scripts/ci-interaction.test.mjs @@ -5,17 +5,16 @@ * * The proofs are coverage inside the shared surface (AST-030 FR11), so the * `test` join requires pr-interaction, and a failing proof fails `test` (FR7). - * A Linux job decides whether the change touches the harness, a component, - * Storybook, or the proofs' own setup; only then does pr-interaction take a - * 4-core runner, and otherwise it passes on Linux without work. The proofs run - * on the pull request's head: a head cut before they existed (no + * The job decides its own scope in its first steps (no separate scope job): + * the proofs apply when the change touches the harness, a component, + * Storybook, or the proofs' own setup; otherwise every later step skips and + * the job reports success. A head cut before the proofs existed (no * playwright.interaction.config.ts) passes with a notice, and a pull request - * that deletes the config still runs them, and fails. + * that deletes the config still runs them, and fails (the scope step's own + * behavior is in test-scope-action.test.mjs). */ -import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import {describe, expect, it} from 'vitest'; @@ -25,77 +24,11 @@ const root = path.resolve(import.meta.dirname, '../..'); const ci = yaml.parse( fs.readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8'), ); -const scope = ci.jobs['interaction-scope']; const proofs = ci.jobs['pr-interaction']; const step = (job, name) => job.steps.find(candidate => candidate.name === name); -const decide = () => step(scope, 'Decide whether the interaction proofs apply'); -const applies = "needs.interaction-scope.outputs.applies == 'true'"; - -const HEAD_SHA = 'fedcba9876543210fedcba9876543210fedcba98'; -const CONFIG_PATTERN = '^playwright\\.interaction\\.config\\.ts$'; - -// Runs a step's script as Actions runs it (bash -eo pipefail) with a stub -// `git` first on PATH: fetching the base exits FETCH and fetching the head -// FETCH_HEAD; `show` prints a helper that answers HELPER, or TOUCHES for the -// config pattern (or fails when HELPER is 'missing'); `cat-file` finds the -// config in the head when HEAD_HAS_CONFIG. Returns the exit status and the -// outputs the script wrote. -function runStep( - script, - { - env = {}, - fetch = 0, - fetchHead = 0, - helper = 0, - touches = 1, - headHasConfig = true, - } = {}, -) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-interaction-')); - try { - const bin = path.join(dir, 'bin'); - fs.mkdirSync(bin); - fs.writeFileSync( - path.join(bin, 'git'), - [ - '#!/usr/bin/env bash', - 'case "$1" in', - ` fetch) case "$*" in *${HEAD_SHA}*) exit ${fetchHead} ;; *) exit ${fetch} ;; esac ;;`, - helper === 'missing' - ? ' show) exit 128 ;;' - : ` show) printf '%s\\n' 'if [ "$2" = "${CONFIG_PATTERN}" ]; then exit ${touches}; fi' 'exit ${helper}' ;;`, - ` cat-file) exit ${headHasConfig ? 0 : 1} ;;`, - ' *) exit 3 ;;', - 'esac', - '', - ].join('\n'), - {mode: 0o755}, - ); - const output = path.join(dir, 'output'); - fs.writeFileSync(output, ''); - const result = spawnSync( - 'bash', - ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], - { - encoding: 'utf8', - env: { - PATH: `${bin}:${process.env.PATH}`, - GITHUB_OUTPUT: output, - EVENT_NAME: 'pull_request', - DOCSITE_ONLY: 'false', - TOOLING_ONLY: 'false', - BASE_SHA: '0123456789abcdef0123456789abcdef01234567', - HEAD_SHA, - ...env, - }, - }, - ); - return {status: result.status, output: fs.readFileSync(output, 'utf8')}; - } finally { - fs.rmSync(dir, {recursive: true, force: true}); - } -} +const decide = () => proofs.steps.find(candidate => candidate.id === 'scope'); +const applies = "steps.scope.outputs.applies == 'true'"; describe('interaction proof routing', () => { it('is joined by test', () => { @@ -108,26 +41,23 @@ describe('interaction proof routing', () => { }); it('always reports, so the join never reads a missing result', () => { - expect(proofs.if).toBe(ci.jobs['test-native-swift'].if); - expect(scope.if).toBe(ci.jobs['native-swift-scope'].if); + expect(proofs.if).toBe( + "${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() }}", + ); + expect(proofs.needs).toEqual(['build-storybook']); }); - it('decides scope on Linux and takes four cores only when the proofs apply', () => { - expect(scope['runs-on']).toBe('ubuntu-slim'); - expect(proofs.needs).toEqual([ - 'check-scope', - 'interaction-scope', - 'build-storybook', - ]); - expect(proofs['runs-on']).toBe( - "${{ needs.interaction-scope.outputs.applies == 'true' && '4-core-ubuntu' || 'ubuntu-slim' }}", + it('decides its own scope first, and gates every later step on it', () => { + expect(proofs.steps[0].with['sparse-checkout']).toBe( + '.github/actions/test-scope', ); + expect(decide().uses).toBe('./.test-scope/.github/actions/test-scope'); + expect(proofs.steps.indexOf(decide())).toBe(1); const guards = new Set([ - 'Require a decided interaction scope', "Require this pull request's Storybook build", 'Skip the proofs outside their scope', ]); - for (const candidate of proofs.steps) { + for (const candidate of proofs.steps.slice(2)) { if (guards.has(candidate.name)) continue; expect(candidate.if, candidate.name ?? candidate.uses).toMatch( new RegExp( @@ -137,106 +67,24 @@ describe('interaction proof routing', () => { } }); - it('fails closed when the scope cannot be decided or Storybook did not build', () => { - expect(step(scope, 'Require successful scope classification').if).toBe( - "needs.check-scope.result != 'success'", - ); - expect(step(proofs, 'Require a decided interaction scope').if).toBe( - "needs.interaction-scope.result != 'success'", - ); + it('fails closed when Storybook did not build for proofs that apply', () => { expect(step(proofs, "Require this pull request's Storybook build").if).toBe( `${applies} && needs.build-storybook.result != 'success'`, ); - expect(decide().run).toContain( - 'git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh"', - ); - // Only "out of scope" (exit 1) skips; unknown (2) runs the proofs. - expect(decide().run).toContain('if [ "$scope" = 1 ]; then'); - }); - - // AST-030 FR9: removing a fail-closed edge fails a test. Only a helper that - // answers "out of scope" (exit 1) skips the proofs; an unknown answer, a - // failed fetch, a helper missing at the base, or no base runs them. - it('runs the proofs unless the helper answers "out of scope"', () => { - const decided = options => runStep(decide().run, options); - for (const [label, options, expected] of [ - ['in scope (helper exit 0)', {helper: 0}, 'true'], - ['out of scope (helper exit 1)', {helper: 1}, 'false'], - ['unknown (helper exit 2)', {helper: 2}, 'true'], - ['the base cannot be fetched', {fetch: 1}, 'true'], - ['the helper is missing at the base', {helper: 'missing'}, 'true'], - ['no base SHA', {env: {BASE_SHA: ''}}, 'true'], - ['docsite only', {env: {DOCSITE_ONLY: 'true'}, helper: 0}, 'false'], - [ - 'not a pull request', - {env: {EVENT_NAME: 'merge_group'}, helper: 0}, - 'false', - ], - ]) { - const {status, output} = decided(options); - expect(status, label).toBe(0); - expect(output, label).toBe(`applies=${expected}\n`); - } - }); - - it('passes a head cut before the proofs existed, and still fails one that deletes their config', () => { - const decided = options => runStep(decide().run, options); - for (const [label, options, expected] of [ - ['the head has the config', {headHasConfig: true}, 'true'], - [ - 'the branch predates the proofs (no config, not changed by the PR)', - {headHasConfig: false, touches: 1}, - 'false', - ], - [ - 'the pull request deletes the config', - {headHasConfig: false, touches: 0}, - 'true', - ], - [ - 'whether the PR changes the config is unknown', - {headHasConfig: false, touches: 2}, - 'true', - ], - [ - 'the head cannot be fetched', - {headHasConfig: false, touches: 1, fetchHead: 1}, - 'true', - ], - [ - 'no head SHA', - {headHasConfig: false, touches: 1, env: {HEAD_SHA: ''}}, - 'true', - ], - [ - 'the scope itself is unknown', - {headHasConfig: false, touches: 1, helper: 2}, - 'true', - ], - ]) { - const {status, output} = decided(options); - expect(status, label).toBe(0); - expect(output, label).toBe(`applies=${expected}\n`); - } - expect(decide().run).toContain( - '::notice title=Interaction proofs predate this branch::', + expect(step(proofs, 'Skip the proofs outside their scope').if).toBe( + "steps.scope.outputs.applies != 'true'", ); }); - it('fails each guard step when it runs', () => { - for (const [job, name] of [ - [scope, 'Require successful scope classification'], - [proofs, 'Require a decided interaction scope'], - [proofs, "Require this pull request's Storybook build"], - ]) { - expect(runStep(step(job, name).run).status, name).toBe(1); - } + it('skips merge-group runs and heads cut before the proofs existed', () => { + expect(decide().with).toMatchObject({ + 'outside-pull-request': 'skip', + 'requires-head-file': 'playwright.interaction.config.ts', + }); }); it('keeps the harness, components, themes, Storybook and its own setup in scope', () => { - const pattern = new RegExp( - decide().run.match(/bash "\$helper" "\$BASE_SHA" '([^']+)'/)[1], - ); + const pattern = new RegExp(decide().with.paths); for (const touched of [ 'internal/interaction-harness/src/scripts.ts', 'internal/a11y-spec/src/storybook.ts', @@ -248,6 +96,8 @@ describe('interaction proof routing', () => { '.github/workflows/ci.yml', 'package.json', 'pnpm-lock.yaml', + // The scope action decides whether the proofs run, so changing it runs them. + '.github/actions/test-scope/action.yml', ]) { expect(pattern.test(touched), touched).toBe(true); } @@ -261,19 +111,4 @@ describe('interaction proof routing', () => { expect(pattern.test(untouched), untouched).toBe(false); } }); - - it('passes without work on docsite-only, tooling-only and merge-group runs', () => { - expect(decide().env).toMatchObject({ - HEAD_SHA: '${{ github.event.pull_request.head.sha }}', - EVENT_NAME: '${{ github.event_name }}', - DOCSITE_ONLY: '${{ needs.check-scope.outputs.docsite_only }}', - TOOLING_ONLY: '${{ needs.check-scope.outputs.tooling_only }}', - }); - expect(decide().run).toContain( - 'if [ "$DOCSITE_ONLY" = true ] || [ "$TOOLING_ONLY" = true ]; then', - ); - expect(decide().run).toContain( - 'if [ "$EVENT_NAME" != pull_request ]; then', - ); - }); }); diff --git a/.github/scripts/ci-native-swift.test.mjs b/.github/scripts/ci-native-swift.test.mjs index 151a534339f24..ef2cd3eb2e050 100644 --- a/.github/scripts/ci-native-swift.test.mjs +++ b/.github/scripts/ci-native-swift.test.mjs @@ -1,12 +1,14 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. /** - * @file Routing contract for the native Swift coverage in the broad CI lane. + * @file Routing contract for the native Swift packages. * - * The Swift packages of @astryxdesign/native build only on macOS. They join - * the `test` owner (AST-030 FR11), but a macOS runner is requested only when - * a Linux job finds a Swift change under packages/native, or a change to the - * lane's own script, so no other pull request waits for one. + * `test` requires test-native-swift. A runner must be picked before its job + * starts, so native-swift-runner, a seconds-long job on a free slim runner, + * runs the same scope step as every test job; test-native-swift takes macOS + * only when native Swift changed, and otherwise passes on Linux without work. + * It is the one job that exists only to decide scope, because only it picks a + * runner. */ import fs from 'node:fs'; @@ -19,10 +21,11 @@ const root = path.resolve(import.meta.dirname, '../..'); const ci = yaml.parse( fs.readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8'), ); -const scope = ci.jobs['native-swift-scope']; +const runner = ci.jobs['native-swift-runner']; const swift = ci.jobs['test-native-swift']; const step = (job, name) => job.steps.find(candidate => candidate.name === name); +const applies = "needs.native-swift-runner.outputs.applies == 'true'"; describe('native Swift routing', () => { it('is joined by test', () => { @@ -34,48 +37,50 @@ describe('native Swift routing', () => { }); }); - it('decides scope on Linux and asks for macOS only when Swift changed', () => { - expect(scope['runs-on']).toBe('ubuntu-slim'); - expect(swift.needs).toEqual(['check-scope', 'native-swift-scope']); + it('decides on Linux and asks for macOS only when Swift changed', () => { + expect(runner['runs-on']).toBe('ubuntu-slim'); + expect(runner.outputs.applies).toBe('${{ steps.scope.outputs.applies }}'); + expect(runner.steps.find(s => s.id === 'scope').uses).toBe( + './.test-scope/.github/actions/test-scope', + ); + expect(swift.needs).toEqual(['native-swift-runner']); expect(swift['runs-on']).toBe( - "${{ needs.native-swift-scope.outputs.applies == 'true' && 'macos-15' || 'ubuntu-slim' }}", + `\${{ ${applies} && 'macos-15' || 'ubuntu-slim' }}`, + ); + expect(swift.if).toBe( + "${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() }}", ); - for (const candidate of swift.steps) { - if (candidate.uses || candidate.name?.startsWith('Build and test')) { - expect(candidate.if, candidate.name ?? candidate.uses).toBe( - "needs.native-swift-scope.outputs.applies == 'true'", - ); - } - } }); - it('fails closed when the scope cannot be decided', () => { - expect(step(scope, 'Require successful scope classification').if).toBe( - "needs.check-scope.result != 'success'", - ); - expect(step(swift, 'Require a decided Swift scope').if).toBe( - "needs.native-swift-scope.result != 'success'", - ); - const decide = step(scope, 'Decide whether native Swift changed').run; - expect(decide).toContain( - 'git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh"', + it('fails closed when the runner cannot be decided', () => { + expect(step(swift, 'Require a decided Swift runner').if).toBe( + "needs.native-swift-runner.result != 'success'", ); - // Only "out of scope" (exit 1) skips; unknown (2) runs the Swift work. - expect(decide).toContain('if [ "$scope" = 1 ]; then'); + for (const candidate of swift.steps) { + if ( + [ + 'Require a decided Swift runner', + 'Skip Swift work outside its scope', + ].includes(candidate.name) + ) { + continue; + } + expect(candidate.if, candidate.name ?? candidate.uses).toBe(applies); + } }); it('keeps its own steps in scope, so changing them runs them', () => { expect(step(swift, 'Build and test each native Swift package').run).toBe( 'bash .github/scripts/native-swift.sh', ); - const decide = step(scope, 'Decide whether native Swift changed').run; const pattern = new RegExp( - decide.match(/bash "\$helper" "\$BASE_SHA" '([^']+)'/)[1], + runner.steps.find(s => s.id === 'scope').with.paths, ); for (const touched of [ '.github/scripts/native-swift.sh', 'packages/native/apple/Package.swift', 'packages/native/swift-ios-1/core/Button/AstryxButton.swift', + '.github/actions/test-scope/action.yml', ]) { expect(pattern.test(touched), touched).toBe(true); } @@ -87,15 +92,4 @@ describe('native Swift routing', () => { expect(pattern.test(untouched), untouched).toBe(false); } }); - - it('skips the Swift work on docsite-only and tooling-only changes', () => { - const decide = step(scope, 'Decide whether native Swift changed'); - expect(decide.env).toMatchObject({ - DOCSITE_ONLY: '${{ needs.check-scope.outputs.docsite_only }}', - TOOLING_ONLY: '${{ needs.check-scope.outputs.tooling_only }}', - }); - expect(decide.run).toContain( - 'if [ "$DOCSITE_ONLY" = true ] || [ "$TOOLING_ONLY" = true ]; then', - ); - }); }); diff --git a/.github/scripts/ci-scope-pr-diff.test.mjs b/.github/scripts/ci-scope-pr-diff.test.mjs deleted file mode 100644 index 7b9a9d3ed806d..0000000000000 --- a/.github/scripts/ci-scope-pr-diff.test.mjs +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -/** - * @file Contract: the interaction and native Swift scope jobs measure the pull - * request's own diff. - * - * Both jobs run `.github/scripts/pr-scope-touches.sh`, which diffs HEAD - * against its merge base with a base commit. From the PR's recorded (often - * old) base, the merge commit carries main's own changes since then, so a - * CLI-only pull request cut before the interaction proofs existed read as - * changing their config. From the PR head, the helper deepens history back to - * an old fork point, which can outlast the job's timeout. Both jobs check out - * the merge commit with its parents and diff it against its first parent: the - * PR's own change against the base it merged into, with no deepening. These - * cases run the real step script and helper with real git, against a local - * origin, checked out the way the workflow checks out; anything undecided - * still runs the proofs. - */ - -import {execFileSync, spawnSync} from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import {afterAll, describe, expect, it} from 'vitest'; -import yaml from 'yaml'; - -const root = path.resolve(import.meta.dirname, '../..'); -const ci = yaml.parse( - fs.readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8'), -); -const HELPER = fs.readFileSync( - path.join(root, '.github/scripts/pr-scope-touches.sh'), - 'utf8', -); - -const checkoutOf = job => - ci.jobs[job].steps.find(step => step.uses?.startsWith('actions/checkout')); -const decideStep = () => - ci.jobs['interaction-scope'].steps.find( - step => step.name === 'Decide whether the interaction proofs apply', - ); - -const REAL_GIT = execFileSync('which', ['git'], {encoding: 'utf8'}).trim(); -const scratch = []; -afterAll(() => { - for (const dir of scratch) { - fs.rmSync(dir, {recursive: true, force: true}); - } -}); - -function git(cwd, ...args) { - return execFileSync(REAL_GIT, args, { - cwd, - encoding: 'utf8', - env: { - ...process.env, - GIT_AUTHOR_NAME: 'ci', - GIT_AUTHOR_EMAIL: 'ci@example.com', - GIT_COMMITTER_NAME: 'ci', - GIT_COMMITTER_EMAIL: 'ci@example.com', - }, - }).trim(); -} - -function write(dir, file, text) { - fs.mkdirSync(path.dirname(path.join(dir, file)), {recursive: true}); - fs.writeFileSync(path.join(dir, file), text); -} - -/** - * A repository whose main moved on after a pull request branched from `base`: - * `baseFiles` at the base, `mainFiles` added on main afterwards, and the PR's - * own `prFiles` (a null value deletes the file). Returns the origin URL and - * the base, head and merge-commit SHAs. - */ -function scenario({ - baseFiles, - mainFiles, - prFiles, - mainCommits = 1, - prMergesMain = 0, -}) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-scope-')); - scratch.push(dir); - const work = path.join(dir, 'work'); - fs.mkdirSync(work); - git(work, 'init', '-q', '-b', 'main'); - write(work, '.github/scripts/pr-scope-touches.sh', HELPER); - for (const [file, text] of Object.entries(baseFiles)) { - write(work, file, text); - } - git(work, 'add', '-A'); - git(work, 'commit', '-q', '-m', 'base'); - const base = git(work, 'rev-parse', 'HEAD'); - - for (const [file, text] of Object.entries(mainFiles)) { - write(work, file, text); - } - git(work, 'add', '-A'); - git(work, 'commit', '-q', '-m', 'main moves on'); - for (let i = 1; i < mainCommits; i++) { - write(work, 'docs/history.md', `${i}\n`); - git(work, 'add', '-A'); - git(work, 'commit', '-q', '-m', `main commit ${i}`); - } - - git(work, 'checkout', '-q', '-b', 'pr', base); - for (const [file, text] of Object.entries(prFiles)) { - if (text === null) { - fs.rmSync(path.join(work, file)); - } else { - write(work, file, text); - } - } - git(work, 'add', '-A'); - git(work, 'commit', '-q', '-m', 'the pull request'); - // A long-lived branch that merges main now and then, which buries the PR's - // recorded base deep in its history. - for (let round = 0; round < prMergesMain; round++) { - git(work, 'checkout', '-q', 'main'); - for (let i = 0; i < 3; i++) { - write(work, 'docs/history.md', `${round}.${i}\n`); - git(work, 'add', '-A'); - git(work, 'commit', '-q', '-m', `main ${round}.${i}`); - } - git(work, 'checkout', '-q', 'pr'); - git(work, 'merge', '-q', '--no-ff', '--no-edit', 'main'); - } - const head = git(work, 'rev-parse', 'HEAD'); - - git(work, 'checkout', '-q', 'main'); - git(work, 'merge', '-q', '--no-ff', '--no-edit', 'pr'); - const merge = git(work, 'rev-parse', 'HEAD'); - - const origin = path.join(dir, 'origin.git'); - git(dir, 'clone', '-q', '--bare', work, origin); - return {url: `file://${origin}`, base, head, merge, dir}; -} - -/** - * Runs the decide step as Actions does, in a checkout made the way the - * interaction-scope job checks out (its `ref`, else the merge commit, at its - * `fetch-depth`). Every git call is logged. Returns the outputs and the log. - */ -function decide(repo, {headSha = repo.head, env = {}} = {}) { - const {ref = '', 'fetch-depth': depth = 1} = - checkoutOf('interaction-scope').with ?? {}; - const checkedOut = ref.includes('pull_request.head.sha') - ? repo.head - : repo.merge; - const workspace = fs.mkdtempSync(path.join(repo.dir, 'ws-')); - git(workspace, 'init', '-q'); - git(workspace, 'remote', 'add', 'origin', repo.url); - git(workspace, 'fetch', '-q', `--depth=${depth}`, 'origin', checkedOut); - git(workspace, 'checkout', '-q', '--detach', checkedOut); - const bin = fs.mkdtempSync(path.join(repo.dir, 'bin-')); - const log = path.join(bin, 'git.log'); - fs.writeFileSync( - path.join(bin, 'git'), - `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> ${JSON.stringify(log)}\nexec ${JSON.stringify(REAL_GIT)} "$@"\n`, - {mode: 0o755}, - ); - fs.writeFileSync(log, ''); - const output = path.join(workspace, '.output'); - fs.writeFileSync(output, ''); - const result = spawnSync( - 'bash', - ['--noprofile', '--norc', '-eo', 'pipefail', '-c', decideStep().run], - { - cwd: workspace, - encoding: 'utf8', - env: { - ...process.env, - PATH: `${bin}:${process.env.PATH}`, - ...env, - GITHUB_OUTPUT: output, - EVENT_NAME: 'pull_request', - DOCSITE_ONLY: 'false', - TOOLING_ONLY: 'false', - BASE_SHA: repo.base, - HEAD_SHA: headSha, - }, - }, - ); - return { - status: result.status, - stdout: result.stdout, - applies: /applies=(\w+)/.exec(fs.readFileSync(output, 'utf8'))?.[1], - gitCalls: fs.readFileSync(log, 'utf8'), - }; -} - -const CONFIG = 'playwright.interaction.config.ts'; - -describe('scope jobs measure the pull request’s own diff', () => { - it('check out the merge commit with its parents', () => { - expect(checkoutOf('interaction-scope').with).toEqual({'fetch-depth': 2}); - expect(checkoutOf('native-swift-scope').with).toEqual({'fetch-depth': 2}); - const swift = ci.jobs['native-swift-scope'].steps.find( - step => step.name === 'Decide whether native Swift changed', - ); - expect(swift.env.BASE_SHA).toContain('merge_group.base_sha'); - expect(swift.run).toContain('[ "$EVENT_NAME" = pull_request ]'); - expect(swift.run).toContain('BASE_SHA="$(git rev-parse HEAD^1)"'); - }); - - it('decides without deepening history, however far main moved on', () => { - const repo = scenario({ - baseFiles: {'packages/cli/scripts/a.mjs': 'a\n'}, - mainFiles: {[CONFIG]: 'config\n'}, - prFiles: {'packages/cli/scripts/a.mjs': 'a2\n'}, - mainCommits: 6, - prMergesMain: 4, - }); - const result = decide(repo, {env: {PR_SCOPE_FETCH_DEPTH: '2'}}); - expect(result).toMatchObject({status: 0, applies: 'false'}); - expect(result.gitCalls).not.toContain('--deepen'); - }); - - it('a CLI-only pull request on a base older than the proofs does not run them', () => { - const repo = scenario({ - baseFiles: {'packages/cli/scripts/a.mjs': 'a\n'}, - mainFiles: {[CONFIG]: 'config\n', 'packages/core/src/b.ts': 'b\n'}, - prFiles: {'packages/cli/scripts/a.mjs': 'a2\n'}, - }); - expect(decide(repo)).toMatchObject({status: 0, applies: 'false'}); - }); - - it('a component pull request on a base older than the proofs skips them with the notice', () => { - const repo = scenario({ - baseFiles: {'packages/core/src/a.ts': 'a\n'}, - mainFiles: {[CONFIG]: 'config\n'}, - prFiles: {'packages/core/src/a.ts': 'a2\n'}, - }); - const result = decide(repo); - expect(result).toMatchObject({status: 0, applies: 'false'}); - expect(result.stdout).toContain('Interaction proofs predate this branch'); - }); - - it('a pull request that really changes the interaction config runs the proofs', () => { - const repo = scenario({ - baseFiles: {[CONFIG]: 'config\n', 'packages/core/src/a.ts': 'a\n'}, - mainFiles: {'packages/core/src/b.ts': 'b\n'}, - prFiles: {[CONFIG]: null}, - }); - expect(decide(repo)).toMatchObject({status: 0, applies: 'true'}); - }); - - it('an unfetchable head runs the proofs (fail safe)', () => { - const repo = scenario({ - baseFiles: {'packages/core/src/a.ts': 'a\n'}, - mainFiles: {[CONFIG]: 'config\n'}, - prFiles: {'packages/core/src/a.ts': 'a2\n'}, - }); - expect(decide(repo, {headSha: 'f'.repeat(40)})).toMatchObject({ - status: 0, - applies: 'true', - }); - }); -}); diff --git a/.github/scripts/ci-test-routing.test.mjs b/.github/scripts/ci-test-routing.test.mjs index 1d45e1e10a474..b5f1ab8e0c74c 100644 --- a/.github/scripts/ci-test-routing.test.mjs +++ b/.github/scripts/ci-test-routing.test.mjs @@ -730,18 +730,13 @@ describe('ci.yml interaction proofs', () => { const step = name => job.steps.find(candidate => candidate.name === name); it('runs the interaction config in Chromium against the exact PR head', () => { - // Four cores when the proofs apply, a slim runner when it passes without work. - const labels = [...job['runs-on'].matchAll(/'([^']+)'/g)] - .map(match => match[1]) - .filter(label => label !== 'true'); - expect(labels).toEqual(['4-core-ubuntu', 'ubuntu-slim']); - for (const label of labels) - expect(KNOWN_RUNNERS.has(label), label).toBe(true); - expect(job.needs).toEqual( - expect.arrayContaining(['check-scope', 'build-storybook']), - ); - const checkout = job.steps.find(candidate => - candidate.uses?.startsWith('actions/checkout@'), + // One fixed runner: out of scope, every step after the scope step skips. + expect(KNOWN_RUNNERS.has(job['runs-on']), job['runs-on']).toBe(true); + expect(job.needs).toEqual(['build-storybook']); + const checkout = job.steps.find( + candidate => + candidate.uses?.startsWith('actions/checkout@') && + candidate.with?.path !== '.test-scope', ); expect(checkout?.with?.ref).toBe( '${{ github.event.pull_request.head.sha }}', @@ -756,11 +751,8 @@ describe('ci.yml interaction proofs', () => { }); it('runs whenever the harness, its config, a component or a story changes', () => { - const scope = workflow.jobs['interaction-scope'].steps.find( - candidate => - candidate.name === 'Decide whether the interaction proofs apply', - ).run; - expect(scope).toContain('.github/scripts/pr-scope-touches.sh'); + const scope = job.steps.find(candidate => candidate.id === 'scope').with + .paths; for (const path of [ 'internal/interaction-harness/', 'internal/a11y-spec/', @@ -773,8 +765,6 @@ describe('ci.yml interaction proofs', () => { ]) { expect(scope).toContain(path); } - // Unknown scope (exit 2) runs the proofs; only "out of scope" skips them. - expect(scope).toContain('if [ "$scope" = 1 ]; then'); }); }); diff --git a/.github/scripts/pr-scope-touches.sh b/.github/scripts/pr-scope-touches.sh deleted file mode 100755 index 2e20bab4e24f6..0000000000000 --- a/.github/scripts/pr-scope-touches.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) Meta Platforms, Inc. and affiliates. -# -# Does this pull request change a path matching PATTERN? -# -# pr-scope-touches.sh BASE_SHA PATTERN -# -# Exit 0: the PR changes a matching path. Exit 1: it does not. Exit 2: the -# merge base could not be found (or the arguments are unusable), so the caller -# cannot tell and should run its evidence, as it did before. -# -# Callers load this script from BASE_SHA, not from the PR head: a PR step's -# body comes from the merge commit, but pr-a11y checks out the PR head, which -# on an older branch does not have this file. -# -# The PR's changes are the diff from its merge base with BASE_SHA to HEAD, -# not from BASE_SHA itself. A branch cut before the base moved would otherwise -# report every file the base added since as a file the PR deleted, and a step -# would run evidence for specs that do not exist at the PR head. -set -uo pipefail - -if [ -z "${1:-}" ] || [ -z "${2:-}" ]; then - echo 'usage: pr-scope-touches.sh BASE_SHA PATTERN' >&2 - exit 2 -fi -base="$1" -pattern="$2" -depth="${PR_SCOPE_FETCH_DEPTH:-200}" # first fetch, then each deepening step -head="$(git rev-parse HEAD)" || exit 2 - -git fetch --quiet --no-tags --depth="$depth" origin "$base" "$head" 2>/dev/null || exit 2 -# Deepen until the merge base is reachable, however far the branch is behind. -until merge_base="$(git merge-base "$base" "$head" 2>/dev/null)"; do - # Complete history and still no merge base: genuinely unknown. - [ "$(git rev-parse --is-shallow-repository)" = true ] || exit 2 - git fetch --quiet --no-tags --deepen="$depth" origin "$base" "$head" 2>/dev/null || exit 2 -done - -# --no-renames lists both sides of a move, so moving a file out of a pattern -# still counts as touching it. -changed="$(git diff --no-renames --name-only "$merge_base" "$head")" || exit 2 -# Not piped into `grep -q`: under pipefail its early exit can fail the pipe. -grep -qE "$pattern" <<<"$changed" -case $? in - 0) exit 0 ;; - 1) exit 1 ;; - *) exit 2 ;; # an invalid pattern must not read as out of scope -esac diff --git a/.github/scripts/pr-scope-touches.test.mjs b/.github/scripts/pr-scope-touches.test.mjs deleted file mode 100644 index 7a051ef2323d9..0000000000000 --- a/.github/scripts/pr-scope-touches.test.mjs +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -import {execFileSync, spawnSync} from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; -import {afterEach, beforeEach, describe, expect, it} from 'vitest'; - -const SCRIPT = path.join( - path.dirname(fileURLToPath(import.meta.url)), - 'pr-scope-touches.sh', -); -const LINK = - '^(internal/a11y-spec/src/patterns/link\\.|packages/core/src/Link/)'; - -let root; -let origin; -let clone; - -const git = (cwd, ...args) => - execFileSync('git', args, { - cwd, - encoding: 'utf8', - env: { - ...process.env, - GIT_AUTHOR_NAME: 't', - GIT_AUTHOR_EMAIL: 't@example.com', - GIT_COMMITTER_NAME: 't', - GIT_COMMITTER_EMAIL: 't@example.com', - }, - }).trim(); - -function commit(cwd, file, message) { - fs.mkdirSync(path.dirname(path.join(cwd, file)), {recursive: true}); - fs.writeFileSync(path.join(cwd, file), `${message}\n`); - git(cwd, 'add', file); - git(cwd, 'commit', '-q', '-m', message); - return git(cwd, 'rev-parse', 'HEAD'); -} - -/** A depth-1 checkout of `sha`, as actions/checkout leaves it. */ -function checkout(sha) { - clone = path.join(root, `clone-${sha.slice(0, 7)}`); - fs.mkdirSync(clone); - git(clone, 'init', '-q'); - git(clone, 'remote', 'add', 'origin', `file://${origin}`); - git(clone, 'fetch', '-q', '--depth=1', 'origin', sha); - git(clone, 'checkout', '-q', '--detach', sha); -} - -function touches(base, pattern = LINK) { - return spawnSync('bash', [SCRIPT, base, pattern], { - cwd: clone, - encoding: 'utf8', - }).status; -} - -beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'pr-scope-')); - origin = path.join(root, 'origin'); - fs.mkdirSync(origin); - git(origin, 'init', '-q', '-b', 'main'); - git(origin, 'config', 'uploadpack.allowAnySHA1InWant', 'true'); - commit(origin, 'README.md', 'root'); -}); - -afterEach(() => { - fs.rmSync(root, {recursive: true, force: true}); -}); - -describe('pr-scope-touches', () => { - it('ignores files the base added after a stale branch was cut', () => { - git(origin, 'checkout', '-q', '-b', 'feature'); - const head = commit(origin, 'packages/core/src/Markdown/x.ts', 'unrelated'); - git(origin, 'checkout', '-q', 'main'); - const base = commit( - origin, - 'packages/core/src/Link/__tests__/Link.a11y.chromium.spec.ts', - 'link contract', - ); - checkout(head); - // A two-dot diff from the base tip lists the Link spec as deleted. - expect(git(clone, 'fetch', '-q', '--depth=1', 'origin', base)).toBe(''); - expect(git(clone, 'diff', '--name-only', base, 'HEAD')).toContain( - 'packages/core/src/Link/', - ); - expect(touches(base)).toBe(1); - }); - - it('reports a branch that changes a matching path', () => { - git(origin, 'checkout', '-q', '-b', 'feature'); - const head = commit( - origin, - 'packages/core/src/Link/Link.tsx', - 'link change', - ); - git(origin, 'checkout', '-q', 'main'); - const base = commit(origin, 'docs/other.md', 'base moved'); - checkout(head); - expect(touches(base)).toBe(0); - }); - - it('reports a branch that deletes a matching path', () => { - const shared = commit(origin, 'packages/core/src/Link/Link.tsx', 'link'); - git(origin, 'checkout', '-q', '-b', 'feature'); - git(origin, 'rm', '-q', 'packages/core/src/Link/Link.tsx'); - git(origin, 'commit', '-q', '-m', 'delete link'); - const head = git(origin, 'rev-parse', 'HEAD'); - checkout(head); - expect(touches(shared)).toBe(0); - }); - - it('deepens until it finds a merge base far behind the base tip', () => { - git(origin, 'checkout', '-q', '-b', 'feature'); - const head = commit(origin, 'a.md', 'a'); - git(origin, 'checkout', '-q', 'main'); - let base = ''; - for (let i = 0; i < 7; i++) base = commit(origin, `b${i}.md`, `b${i}`); - base = commit( - origin, - 'packages/core/src/Link/__tests__/Link.a11y.chromium.spec.ts', - 'link contract', - ); - checkout(head); - const status = spawnSync('bash', [SCRIPT, base, LINK], { - cwd: clone, - encoding: 'utf8', - env: {...process.env, PR_SCOPE_FETCH_DEPTH: '2'}, - }).status; - expect(status).toBe(1); - }); - - it('is unknown for unrelated histories', () => { - const base = commit(origin, 'a.md', 'a'); - git(origin, 'checkout', '-q', '--orphan', 'other'); - git(origin, 'rm', '-rqf', '.'); - const head = commit(origin, 'packages/x.md', 'unrelated root'); - checkout(head); - expect(touches(base)).toBe(2); - }); - - it('counts moving a matching file out of the pattern', () => { - commit(origin, 'apps/storybook/stories/LinkA11y.stories.tsx', 'story'); - const shared = git(origin, 'rev-parse', 'HEAD'); - git(origin, 'checkout', '-q', '-b', 'feature'); - git( - origin, - 'mv', - 'apps/storybook/stories/LinkA11y.stories.tsx', - 'apps/storybook/stories/Moved.stories.tsx', - ); - git(origin, 'commit', '-q', '-m', 'move'); - checkout(git(origin, 'rev-parse', 'HEAD')); - expect( - touches( - shared, - '^(packages/core/src/Link/|apps/storybook/stories/LinkA11y)', - ), - ).toBe(0); - }); - - it('is unknown, not out of scope, for unusable arguments', () => { - const head = commit(origin, 'a.md', 'a'); - checkout(head); - expect(touches(head, '(')).toBe(2); - expect( - spawnSync('bash', [SCRIPT, head], {cwd: clone, encoding: 'utf8'}).status, - ).toBe(2); - }); - - it('is loaded from the base by every workflow step that uses it', () => { - const workflow = fs.readFileSync( - path.join(path.dirname(SCRIPT), '../workflows/ci.yml'), - 'utf8', - ); - const uses = workflow.match(/pr-scope-touches\.sh/g) ?? []; - const fromBase = - workflow.match( - /git show "\$BASE_SHA:\.github\/scripts\/pr-scope-touches\.sh" > "\$helper"/g, - ) ?? []; - // Every consumer loads the helper from the base; the count is a floor, - // not an exact tally. - expect(uses.length).toBeGreaterThanOrEqual(1); - expect(fromBase.length).toBe(uses.length); - expect(workflow).not.toMatch(/bash \.github\/scripts\/pr-scope-touches/); - }); - - it('is unknown when the base cannot be fetched', () => { - const head = commit(origin, 'a.md', 'a'); - checkout(head); - expect(touches('f'.repeat(40))).toBe(2); - }); -}); diff --git a/.github/scripts/test-scope-action.test.mjs b/.github/scripts/test-scope-action.test.mjs new file mode 100644 index 0000000000000..445defdb48e4b --- /dev/null +++ b/.github/scripts/test-scope-action.test.mjs @@ -0,0 +1,309 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Behavior of the per-job scope step (.github/actions/test-scope). + * + * Every job that owns tests decides its own scope with this step; there is no + * separate scope job. These cases run the action's real script, as + * actions/github-script runs it, against a stubbed GitHub API that serves the + * repository's real classifier files as the base's trusted policy. Anything + * the step cannot decide must put everything in scope (spec:AST-030 FR3, FR4). + */ + +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import path from 'node:path'; + +import {describe, expect, it} from 'vitest'; +import yaml from 'yaml'; + +const root = path.resolve(import.meta.dirname, '../..'); +const action = yaml.parse( + fs.readFileSync( + path.join(root, '.github/actions/test-scope/action.yml'), + 'utf8', + ), +); +const scriptStep = action.runs.steps.find(step => + step.uses?.startsWith('actions/github-script@'), +); +const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; +const requireFromHere = createRequire(import.meta.url); + +const BASE_SHA = 'b'.repeat(40); +const HEAD_SHA = 'h'.repeat(40); +const INTERACTION_PATHS = + '^(internal/interaction-harness/|internal/a11y-spec/|playwright\\.interaction\\.config\\.ts$|packages/[^/]+/src/|packages/themes/[^/]+/src/|apps/storybook/|\\.github/workflows/ci\\.yml$|package\\.json$|pnpm-lock\\.yaml$|\\.github/actions/test-scope/)'; +const CLASSIFIER_FILES = new Set([ + '.github/scripts/change-scope.cjs', + '.github/scripts/knowledge-paths.cjs', + 'scripts/component-packages.cjs', +]); + +function pullRequestContext(baseRef = 'main') { + return { + eventName: 'pull_request', + repo: {owner: 'o', repo: 'r'}, + payload: { + pull_request: { + number: 7, + base: {ref: baseRef, sha: BASE_SHA}, + head: {sha: HEAD_SHA}, + }, + }, + }; +} + +/** + * Runs the action script. `files` is GitHub's file list (or an Error to + * throw); `headFiles` are the paths present at the PR head; `missingBase` + * removes classifier files from the base. + */ +async function decide({ + context = pullRequestContext(), + files = [], + inputs = {}, + headFiles = [], + missingBase = [], +} = {}) { + const outputs = {}; + const messages = {info: [], warning: [], notice: [], failed: []}; + const contentCalls = []; + const core = { + setOutput: (key, value) => { + outputs[key] = value; + }, + info: message => messages.info.push(message), + warning: message => messages.warning.push(message), + notice: message => messages.notice.push(message), + setFailed: message => messages.failed.push(message), + }; + const notFound = () => Object.assign(new Error('Not Found'), {status: 404}); + const github = { + paginate: async () => { + if (files instanceof Error) throw files; + return files; + }, + rest: { + pulls: {listFiles: () => {}}, + repos: { + getContent: async ({path: file, ref}) => { + contentCalls.push({file, ref}); + if (ref === BASE_SHA && CLASSIFIER_FILES.has(file)) { + if (missingBase.includes(file)) throw notFound(); + return { + data: { + content: fs + .readFileSync(path.join(root, file)) + .toString('base64'), + }, + }; + } + if (ref === HEAD_SHA && headFiles.includes(file)) { + return {data: {content: ''}}; + } + throw notFound(); + }, + }, + }, + }; + const env = { + SCOPE_PATHS: inputs.paths ?? '', + SCOPE_OUTSIDE_PR: inputs['outside-pull-request'] ?? 'run', + SCOPE_HEAD_FILE: inputs['requires-head-file'] ?? '', + }; + const saved = {...process.env}; + Object.assign(process.env, env); + try { + await new AsyncFunction( + 'require', + 'github', + 'context', + 'core', + scriptStep.with.script, + )(requireFromHere, github, context, core); + } finally { + for (const key of Object.keys(env)) { + if (key in saved) process.env[key] = saved[key]; + else delete process.env[key]; + } + } + return {outputs, messages, contentCalls}; +} + +const changed = (...names) => + names.map(filename => ({filename, status: 'modified'})); + +describe('per-job scope step', () => { + it('is one github-script step that retries server errors', () => { + expect(action.runs.using).toBe('composite'); + expect(scriptStep.with.retries).toBeGreaterThanOrEqual(1); + expect(Object.keys(action.outputs).sort()).toEqual( + [ + 'applies', + 'docsite_only', + 'full_suite', + 'release_bump', + 'spec_only', + 'tooling_only', + 'touches', + ].sort(), + ); + }); + + it('puts everything in scope outside a pull request', async () => { + const context = {eventName: 'merge_group', repo: {owner: 'o', repo: 'r'}}; + const {outputs} = await decide({context}); + expect(outputs).toMatchObject({ + spec_only: 'false', + docsite_only: 'false', + tooling_only: 'false', + full_suite: 'true', + applies: 'true', + }); + const skipped = await decide({ + context, + inputs: {'outside-pull-request': 'skip'}, + }); + expect(skipped.outputs.applies).toBe('false'); + }); + + it('marks a release-branch pull request as the generated version bump', async () => { + const {outputs} = await decide({ + context: pullRequestContext('release/v1.2.3'), + }); + expect(outputs).toMatchObject({ + release_bump: 'true', + spec_only: 'true', + tooling_only: 'true', + applies: 'false', + }); + }); + + it('classifies the PR file list with the classifier read from the base commit', async () => { + const {outputs, contentCalls} = await decide({ + files: changed('packages/core/src/Button/Button.tsx'), + inputs: {paths: INTERACTION_PATHS}, + }); + expect(outputs).toMatchObject({ + spec_only: 'false', + docsite_only: 'false', + tooling_only: 'false', + release_bump: 'false', + touches: 'true', + applies: 'true', + }); + expect( + contentCalls.filter(call => CLASSIFIER_FILES.has(call.file)), + ).toEqual([...CLASSIFIER_FILES].map(file => ({file, ref: BASE_SHA}))); + }); + + it('does not apply when no changed path is in the declared scope', async () => { + const {outputs} = await decide({ + files: changed('packages/cli/assets/codemods/registry.json'), + inputs: {paths: INTERACTION_PATHS}, + }); + expect(outputs).toMatchObject({touches: 'false', applies: 'false'}); + }); + + it('matches the old name of a rename', async () => { + const {outputs} = await decide({ + files: [ + { + filename: 'packages/cli/assets/moved.ts', + previous_filename: 'internal/interaction-harness/moved.ts', + status: 'renamed', + }, + ], + inputs: {paths: INTERACTION_PATHS}, + }); + expect(outputs.touches).toBe('true'); + }); + + it('does not apply on a specialized lane even when a path matches', async () => { + const {outputs} = await decide({ + files: changed('docs/specs/AST-030/spec.md'), + }); + expect(outputs).toMatchObject({spec_only: 'true', applies: 'false'}); + }); + + it('fails when the trusted classifier rejects the change', async () => { + const {messages} = await decide({ + files: [ + ...changed('docs/specs/AST-030/spec.md'), + {filename: '.changeset/new-thing.md', status: 'added'}, + ], + }); + expect(messages.failed).toHaveLength(1); + }); + + it.each([ + ['the file list cannot be read', {files: new Error('502 Bad Gateway')}], + [ + 'GitHub truncates the file list', + { + files: Array.from({length: 3000}, (_, i) => ({ + filename: `docs/specs/AST-${i}/spec.md`, + status: 'modified', + })), + }, + ], + ['GitHub lists no changed files', {files: []}], + [ + 'a classifier file is missing at the base', + { + files: changed('docs/specs/AST-030/spec.md'), + missingBase: ['scripts/component-packages.cjs'], + }, + ], + ])('puts everything in scope when %s', async (_, options) => { + const {outputs, messages} = await decide({ + ...options, + inputs: {paths: INTERACTION_PATHS}, + }); + expect(outputs).toMatchObject({ + spec_only: 'false', + docsite_only: 'false', + tooling_only: 'false', + full_suite: 'true', + touches: 'true', + applies: 'true', + }); + expect(messages.warning).toHaveLength(1); + }); + + describe('requires-head-file', () => { + const inputs = { + paths: INTERACTION_PATHS, + 'requires-head-file': 'playwright.interaction.config.ts', + }; + + it('does not apply, with a notice, to a head cut before the file existed', async () => { + const {outputs, messages} = await decide({ + files: changed('packages/core/src/Button/Button.tsx'), + inputs, + }); + expect(outputs.applies).toBe('false'); + expect(messages.notice).toHaveLength(1); + }); + + it('applies when the head has the file', async () => { + const {outputs} = await decide({ + files: changed('packages/core/src/Button/Button.tsx'), + headFiles: ['playwright.interaction.config.ts'], + inputs, + }); + expect(outputs.applies).toBe('true'); + }); + + it('still applies to a pull request that deletes the file, so the check fails', async () => { + const {outputs} = await decide({ + files: [ + {filename: 'playwright.interaction.config.ts', status: 'removed'}, + ], + inputs, + }); + expect(outputs.applies).toBe('true'); + }); + }); +}); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 436ca28c2fac4..cf47dbe217dd5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -804,95 +804,58 @@ jobs: if: matrix.shard == 1 && needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' run: pnpm test:theme-family - # Native Swift: the Swift packages of the experimental, canary-only - # @astryxdesign/native (AST-082) build and test only on macOS. They are - # coverage inside the shared surface (AST-030 FR11), so `test` joins - # test-native-swift. A Linux job first decides whether the change touches - # Swift under packages/native or the lane's own script; only then does - # test-native-swift take a macOS runner, and otherwise it passes on Linux - # without work. - native-swift-scope: - needs: [check-scope] - if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }} + # The one pre-job left: a runner must be picked before its job starts, so + # this decides on a free slim runner whether native Swift changed, and + # test-native-swift runs on macOS only then. It reads the same scope step as + # every test job; anything undecidable picks macOS. + native-swift-runner: + if: ${{ github.event_name != 'workflow_dispatch' }} runs-on: ubuntu-slim + timeout-minutes: 5 permissions: contents: read + pull-requests: read outputs: applies: ${{ steps.scope.outputs.applies }} steps: - - name: Require successful scope classification - if: needs.check-scope.result != 'success' - run: | - echo "Change scope could not be classified; refusing to skip required work." - exit 1 - - # The merge commit and its parents (see interaction-scope). - - uses: actions/checkout@v7 - if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' + - name: Check out the scope action + uses: actions/checkout@v7 with: - fetch-depth: 2 + sparse-checkout: .github/actions/test-scope + path: .test-scope + persist-credentials: false - # The helper comes from the base, since an older head lacks it. Exit 1 - # means out of scope; 0 in scope; 2 unknown, so the Swift work runs. - name: Decide whether native Swift changed id: scope - env: - EVENT_NAME: ${{ github.event_name }} - DOCSITE_ONLY: ${{ needs.check-scope.outputs.docsite_only }} - TOOLING_ONLY: ${{ needs.check-scope.outputs.tooling_only }} - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} - run: | - if [ "$DOCSITE_ONLY" = true ] || [ "$TOOLING_ONLY" = true ]; then - echo 'Specialized lane; no native Swift work.' - echo 'applies=false' >> "$GITHUB_OUTPUT" - exit 0 - fi - # A pull request diffs against its merge commit's first parent, as - # interaction-scope does; the merge queue keeps its base. - if [ "$EVENT_NAME" = pull_request ] \ - && [ "$(git rev-list --parents -n 1 HEAD 2>/dev/null | wc -w)" -eq 3 ]; then - BASE_SHA="$(git rev-parse HEAD^1)" - fi - scope=2 - helper="$(mktemp)" - if [ -n "$BASE_SHA" ] \ - && git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" \ - && git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh" > "$helper"; then - scope=0 - bash "$helper" "$BASE_SHA" '^packages/native/.*(\.swift|/Package\.swift)$|^\.github/scripts/native-swift\.sh$' || scope=$? - fi - if [ "$scope" = 1 ]; then - echo 'No native Swift change; nothing to build.' - echo 'applies=false' >> "$GITHUB_OUTPUT" - else - echo 'applies=true' >> "$GITHUB_OUTPUT" - fi + uses: ./.test-scope/.github/actions/test-scope + with: + paths: '^packages/native/.*(\.swift|/Package\.swift)$|^\.github/scripts/native-swift\.sh$|^\.github/actions/test-scope/' test-native-swift: - needs: [check-scope, native-swift-scope] - if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }} - runs-on: ${{ needs.native-swift-scope.outputs.applies == 'true' && 'macos-15' || 'ubuntu-slim' }} + needs: [native-swift-runner] + if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() }} + runs-on: ${{ needs.native-swift-runner.outputs.applies == 'true' && 'macos-15' || 'ubuntu-slim' }} timeout-minutes: 30 permissions: contents: read steps: - - name: Require a decided Swift scope - if: needs.native-swift-scope.result != 'success' + - name: Require a decided Swift runner + if: needs.native-swift-runner.result != 'success' run: | - echo "Native Swift scope could not be decided; refusing to skip required work." + echo "The Swift runner could not be decided; refusing to skip required work." exit 1 - name: Skip Swift work outside its scope - if: needs.native-swift-scope.outputs.applies != 'true' + if: needs.native-swift-runner.outputs.applies != 'true' run: echo "No native Swift change in this scope." - uses: actions/checkout@v7 - if: needs.native-swift-scope.outputs.applies == 'true' + if: needs.native-swift-runner.outputs.applies == 'true' # The steps live in native-swift.sh, which is in the native Swift scope, # so a change to them runs on its own pull request. - name: Build and test each native Swift package - if: needs.native-swift-scope.outputs.applies == 'true' + if: needs.native-swift-runner.outputs.applies == 'true' run: bash .github/scripts/native-swift.sh # Public registry contract. Generation proves schema, dependency, route-lock, @@ -1840,158 +1803,75 @@ jobs: retention-days: 30 delete-merged: true - # Touch interaction and animation proofs: the harness in - # internal/interaction-harness and every *.interaction.browser.spec.ts. - # Real touch input, composited frames, rendering traces and presented - # animation values only exist in a real engine, and the specs read stories - # from this PR's own Storybook build. The harness's self-test runs with them, - # so a change to an instrument is proven by its own checks before a component - # spec relies on it. Chromium only on these runners; see the harness README - # for running WebKit. - # - # The proofs are coverage inside the shared surface (AST-030 FR11), so `test` - # joins pr-interaction. A Linux job first decides whether the change touches - # the harness, a component, Storybook, or the proofs' own setup; only then - # does pr-interaction take a 4-core runner, and otherwise it passes on Linux - # without work. Like pr-a11y, the proofs run on pull requests; a merge group - # passes without them. - interaction-scope: - needs: [check-scope] - if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }} - runs-on: ubuntu-slim - timeout-minutes: 5 - permissions: - contents: read - outputs: - applies: ${{ steps.scope.outputs.applies }} - steps: - - name: Require successful scope classification - if: needs.check-scope.result != 'success' - run: | - echo "Change scope could not be classified; refusing to skip required work." - exit 1 - - # The PR merge commit and its parents: the first parent is the base it - # merged into, so the PR's own change is one diff away (see below). - - uses: actions/checkout@v7 - if: github.event_name == 'pull_request' && needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.tooling_only != 'true' - with: - fetch-depth: 2 - - # The helper comes from the base, since an older head lacks it. Exit 1 - # means out of scope; 0 in scope; 2 unknown, so the proofs run. - # pr-interaction runs the proofs on the PR head. A head cut before they - # existed has no playwright.interaction.config.ts and passes with a - # notice; a PR that deletes the config (its diff changes it) still runs - # them, and fails. Anything undecided runs them. - - name: Decide whether the interaction proofs apply - id: scope - env: - EVENT_NAME: ${{ github.event_name }} - DOCSITE_ONLY: ${{ needs.check-scope.outputs.docsite_only }} - TOOLING_ONLY: ${{ needs.check-scope.outputs.tooling_only }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - if [ "$DOCSITE_ONLY" = true ] || [ "$TOOLING_ONLY" = true ]; then - echo 'Specialized lane; no interaction proofs.' - echo 'applies=false' >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "$EVENT_NAME" != pull_request ]; then - echo 'Not a pull request; the proofs ran on the pull request itself.' - echo 'applies=false' >> "$GITHUB_OUTPUT" - exit 0 - fi - # Diff against the merge commit's first parent, not the PR's - # recorded base: from an old base the diff would count main's own - # changes since as the PR's, and from the PR head the helper would - # deepen history back to an old fork point. - if [ "$(git rev-list --parents -n 1 HEAD 2>/dev/null | wc -w)" -eq 3 ]; then - BASE_SHA="$(git rev-parse HEAD^1)" - fi - scope=2 - helper="$(mktemp)" - if [ -n "$BASE_SHA" ] \ - && git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" \ - && git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh" > "$helper"; then - scope=0 - bash "$helper" "$BASE_SHA" '^(internal/interaction-harness/|internal/a11y-spec/|playwright\.interaction\.config\.ts$|packages/[^/]+/src/|packages/themes/[^/]+/src/|apps/storybook/|\.github/workflows/ci\.yml$|package\.json$|pnpm-lock\.yaml$)' || scope=$? - fi - if [ "$scope" = 1 ]; then - echo 'No harness, component, Storybook or proof-setup change; skipping the interaction proofs.' - echo 'applies=false' >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "$scope" = 0 ] && [ -n "$HEAD_SHA" ] \ - && git fetch --quiet --no-tags --depth=1 origin "$HEAD_SHA" \ - && ! git cat-file -e "$HEAD_SHA:playwright.interaction.config.ts" 2>/dev/null; then - touches=0 - bash "$helper" "$BASE_SHA" '^playwright\.interaction\.config\.ts$' || touches=$? - if [ "$touches" = 1 ]; then - echo "::notice title=Interaction proofs predate this branch::This pull request's head has no playwright.interaction.config.ts; merge main to run the interaction proofs." - echo 'applies=false' >> "$GITHUB_OUTPUT" - exit 0 - fi - fi - echo 'applies=true' >> "$GITHUB_OUTPUT" - pr-interaction: - needs: [check-scope, interaction-scope, build-storybook] - if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }} + needs: [build-storybook] + if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() }} # Frame evidence measures timing: four cores, one Playwright worker. - runs-on: ${{ needs.interaction-scope.outputs.applies == 'true' && '4-core-ubuntu' || 'ubuntu-slim' }} + runs-on: 4-core-ubuntu timeout-minutes: 15 permissions: contents: read + pull-requests: read steps: - - name: Require a decided interaction scope - if: needs.interaction-scope.result != 'success' - run: | - echo "Interaction scope could not be decided; refusing to skip required work." - exit 1 + # This job decides its own scope (no separate scope job): the step + # reads the PR's file list and the base's trusted classifier, and + # everything is in scope when it cannot decide. + - name: Check out the scope action + uses: actions/checkout@v7 + with: + sparse-checkout: .github/actions/test-scope + path: .test-scope + persist-credentials: false + + - name: Decide this job's scope + id: scope + uses: ./.test-scope/.github/actions/test-scope + with: + paths: '^(internal/interaction-harness/|internal/a11y-spec/|playwright\.interaction\.config\.ts$|packages/[^/]+/src/|packages/themes/[^/]+/src/|apps/storybook/|\.github/workflows/ci\.yml$|package\.json$|pnpm-lock\.yaml$|\.github/actions/test-scope/)' + outside-pull-request: skip + requires-head-file: playwright.interaction.config.ts - name: Require this pull request's Storybook build - if: needs.interaction-scope.outputs.applies == 'true' && needs.build-storybook.result != 'success' + if: steps.scope.outputs.applies == 'true' && needs.build-storybook.result != 'success' run: | echo "The proofs read this pull request's Storybook build, which did not succeed." exit 1 - name: Skip the proofs outside their scope - if: needs.interaction-scope.outputs.applies != 'true' + if: steps.scope.outputs.applies != 'true' run: echo "No interaction proofs for this change." - name: Checkout - if: needs.interaction-scope.outputs.applies == 'true' + if: steps.scope.outputs.applies == 'true' uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha }} - name: Verify evidence checkout - if: needs.interaction-scope.outputs.applies == 'true' + if: steps.scope.outputs.applies == 'true' env: EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD_SHA" - name: Setup Node and pnpm - if: needs.interaction-scope.outputs.applies == 'true' + if: steps.scope.outputs.applies == 'true' uses: ./.github/actions/setup with: install: 'false' - name: Download Storybook artifact - if: needs.interaction-scope.outputs.applies == 'true' + if: steps.scope.outputs.applies == 'true' uses: actions/download-artifact@v8 with: name: storybook-${{ needs.build-storybook.outputs.short_hash }} path: apps/storybook/dist - name: Install Playwright - if: needs.interaction-scope.outputs.applies == 'true' + if: steps.scope.outputs.applies == 'true' run: pnpm install --frozen-lockfile && npx playwright install chromium - name: Run interaction proofs - if: needs.interaction-scope.outputs.applies == 'true' + if: steps.scope.outputs.applies == 'true' run: | pnpm exec playwright test \ --config playwright.interaction.config.ts \ @@ -2000,7 +1880,7 @@ jobs: # Storyboards (PNG contact sheets) and curve traces, for review. - name: Upload interaction evidence - if: always() && needs.interaction-scope.outputs.applies == 'true' + if: always() && steps.scope.outputs.applies == 'true' uses: actions/upload-artifact@v7 with: name: interaction-evidence @@ -2717,7 +2597,7 @@ jobs: check-components, test-ui, test-node, - native-swift-scope, + native-swift-runner, test-native-swift, registry-contract, test, @@ -2729,7 +2609,6 @@ jobs: pr-a11y-guards, pr-a11y-specs, pr-a11y, - interaction-scope, pr-interaction, test-build, visual-scout, diff --git a/docs/specs/AST-030/spec.md b/docs/specs/AST-030/spec.md index b42e50e577891..92b48c248d0f7 100644 --- a/docs/specs/AST-030/spec.md +++ b/docs/specs/AST-030/spec.md @@ -37,7 +37,7 @@ affects_consumer_docs: [] ".github/workflows/**", ".github/scripts/change-scope.*", ".github/scripts/*routing*.test.mjs", - ".github/scripts/pr-scope-touches.*" + ".github/actions/test-scope/**" ] } ``` From 56ea52ef3efd83ad7441ff30bc288efca7355529 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 29 Oct 2026 21:49:31 -0700 Subject: [PATCH 3/6] test: pin two fail-closed edges of the interaction scope A head-file lookup that fails for a reason other than 404 keeps the proofs in scope with no notice, and the Storybook guard fails the job when it runs (spec:AST-030 FR9). --- .github/scripts/ci-interaction.test.mjs | 8 ++++++++ .github/scripts/test-scope-action.test.mjs | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/.github/scripts/ci-interaction.test.mjs b/.github/scripts/ci-interaction.test.mjs index c06f20827ea2c..b136680e82ce0 100644 --- a/.github/scripts/ci-interaction.test.mjs +++ b/.github/scripts/ci-interaction.test.mjs @@ -14,6 +14,7 @@ * behavior is in test-scope-action.test.mjs). */ +import {spawnSync} from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -74,6 +75,13 @@ describe('interaction proof routing', () => { expect(step(proofs, 'Skip the proofs outside their scope').if).toBe( "steps.scope.outputs.applies != 'true'", ); + // When it runs, the guard fails the job. + const guard = spawnSync( + 'bash', + ['-c', step(proofs, "Require this pull request's Storybook build").run], + {encoding: 'utf8'}, + ); + expect(guard.status).toBe(1); }); it('skips merge-group runs and heads cut before the proofs existed', () => { diff --git a/.github/scripts/test-scope-action.test.mjs b/.github/scripts/test-scope-action.test.mjs index 445defdb48e4b..d7ea08d569ba2 100644 --- a/.github/scripts/test-scope-action.test.mjs +++ b/.github/scripts/test-scope-action.test.mjs @@ -64,6 +64,7 @@ async function decide({ files = [], inputs = {}, headFiles = [], + headError, missingBase = [], } = {}) { const outputs = {}; @@ -99,6 +100,9 @@ async function decide({ }, }; } + if (ref === HEAD_SHA && headError) { + throw Object.assign(new Error('Server Error'), {status: headError}); + } if (ref === HEAD_SHA && headFiles.includes(file)) { return {data: {content: ''}}; } @@ -287,6 +291,16 @@ describe('per-job scope step', () => { expect(messages.notice).toHaveLength(1); }); + it('still applies when the head lookup fails for another reason', async () => { + const {outputs, messages} = await decide({ + files: changed('packages/core/src/Button/Button.tsx'), + inputs, + headError: 500, + }); + expect(outputs.applies).toBe('true'); + expect(messages.notice).toHaveLength(0); + }); + it('applies when the head has the file', async () => { const {outputs} = await decide({ files: changed('packages/core/src/Button/Button.tsx'), From c4a1eade029c61f5a7a07c50ea55badd894b17f3 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 29 Oct 2026 22:50:09 -0700 Subject: [PATCH 4/6] test: pin the rename edges of the scope action A rename into spec records is classified by both names, so it is not spec-only; a pull request that renames the required head file away still applies, with no notice (spec:AST-030 FR9). --- .github/scripts/test-scope-action.test.mjs | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/scripts/test-scope-action.test.mjs b/.github/scripts/test-scope-action.test.mjs index d7ea08d569ba2..4a729f2926119 100644 --- a/.github/scripts/test-scope-action.test.mjs +++ b/.github/scripts/test-scope-action.test.mjs @@ -276,6 +276,20 @@ describe('per-job scope step', () => { expect(messages.warning).toHaveLength(1); }); + it('classifies a rename by both names, so a rename into specs is not spec-only', async () => { + const {outputs} = await decide({ + files: [ + { + filename: 'docs/specs/AST-030/Button.md', + previous_filename: 'packages/core/src/Button/Button.tsx', + status: 'renamed', + }, + ], + }); + expect(outputs.spec_only).toBe('false'); + expect(outputs.applies).toBe('true'); + }); + describe('requires-head-file', () => { const inputs = { paths: INTERACTION_PATHS, @@ -291,6 +305,21 @@ describe('per-job scope step', () => { expect(messages.notice).toHaveLength(1); }); + it('still applies to a pull request that renames the file away, with no notice', async () => { + const {outputs, messages} = await decide({ + files: [ + { + filename: 'playwright.old-interaction.config.ts', + previous_filename: 'playwright.interaction.config.ts', + status: 'renamed', + }, + ], + inputs, + }); + expect(outputs.applies).toBe('true'); + expect(messages.notice).toHaveLength(0); + }); + it('still applies when the head lookup fails for another reason', async () => { const {outputs, messages} = await decide({ files: changed('packages/core/src/Button/Button.tsx'), From 45ae1872458c8787afa8eeea44cf1f691c4b917a Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Thu, 29 Oct 2026 23:22:47 -0700 Subject: [PATCH 5/6] test: the rename case uses a real spec record; pin the merge-queue Swift run The rename-into-specs case now targets docs/specs/AST-030/spec.md, so it fails if a rename reaches the classifier without its old name, and the native Swift runner job is pinned to run its build outside a pull request (spec:AST-030 FR9). --- .github/scripts/ci-native-swift.test.mjs | 14 ++++++++++++++ .github/scripts/test-scope-action.test.mjs | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/scripts/ci-native-swift.test.mjs b/.github/scripts/ci-native-swift.test.mjs index ef2cd3eb2e050..a45dfeb05a1ca 100644 --- a/.github/scripts/ci-native-swift.test.mjs +++ b/.github/scripts/ci-native-swift.test.mjs @@ -28,6 +28,20 @@ const step = (job, name) => const applies = "needs.native-swift-runner.outputs.applies == 'true'"; describe('native Swift routing', () => { + it('runs the macOS build on a merge-queue entry, where there is no PR file list', () => { + const scope = runner.steps.find(candidate => candidate.id === 'scope'); + const action = yaml.parse( + fs.readFileSync( + path.join(root, '.github/actions/test-scope/action.yml'), + 'utf8', + ), + ); + const outside = + scope.with?.['outside-pull-request'] ?? + action.inputs['outside-pull-request'].default; + expect(outside).toBe('run'); + }); + it('is joined by test', () => { expect(ci.jobs.test.needs).toContain('test-native-swift'); expect( diff --git a/.github/scripts/test-scope-action.test.mjs b/.github/scripts/test-scope-action.test.mjs index 4a729f2926119..8e7caf4bd9acd 100644 --- a/.github/scripts/test-scope-action.test.mjs +++ b/.github/scripts/test-scope-action.test.mjs @@ -280,7 +280,7 @@ describe('per-job scope step', () => { const {outputs} = await decide({ files: [ { - filename: 'docs/specs/AST-030/Button.md', + filename: 'docs/specs/AST-030/spec.md', previous_filename: 'packages/core/src/Button/Button.tsx', status: 'renamed', }, From 3ac9e7031e906f86748b9fe8af1c8162d9cd8bfd Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 02:55:34 -0700 Subject: [PATCH 6/6] ci: the fold comment names no nonexistent scope --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd0e907d5170c..76163be47e3e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2068,7 +2068,7 @@ jobs: # The vibe-tests fixtures share this Playwright-equipped runner: the # canonical setup compositions and the guest fixture contrast need a - # production browser render (registry scope test-theme-e2e). + # production browser render. - name: Verify canonical setup compositions env: ASTRYX_CANONICAL_SETUP_BROWSER: '1'