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 277f53ff19aab..cd075ab85928e 100644 --- a/.github/scripts/ci-interaction.test.mjs +++ b/.github/scripts/ci-interaction.test.mjs @@ -5,97 +5,33 @@ * * 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'; import yaml from 'yaml'; +import {ungated} from './ci-pr-gate.mjs'; + 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 +44,25 @@ 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); + // Behind the pull-request gate (spec:AST-030 FR17). + expect(proofs.if).toMatch(/^\$\{\{ \(github\.event_name != 'pull_request'/); + expect(ungated(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' && 'ubuntu-latest' || '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 +72,31 @@ 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"', + expect(step(proofs, 'Skip the proofs outside their scope').if).toBe( + "steps.scope.outputs.applies != 'true'", ); - // 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::', + // 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('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 +108,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 +123,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..944ff89dba58b 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'; @@ -15,16 +17,33 @@ import path from 'node:path'; import {describe, expect, it} from 'vitest'; import yaml from 'yaml'; +import {ungated} from './ci-pr-gate.mjs'; + 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('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( @@ -34,48 +53,52 @@ 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' }}`, + ); + // Behind the pull-request gate (spec:AST-030 FR17). + expect(swift.if).toMatch(/^\$\{\{ \(github\.event_name != 'pull_request'/); + expect(ungated(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'", + 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'", ); - 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"', - ); - // 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 +110,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 11667bda32d43..8041c30f384a7 100644 --- a/.github/scripts/ci-test-routing.test.mjs +++ b/.github/scripts/ci-test-routing.test.mjs @@ -731,17 +731,9 @@ 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(['ubuntu-latest', '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']), - ); - // The workspace checkout; the heavy-suite caps load from a side path. + // 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@') && @@ -760,11 +752,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/', @@ -777,8 +766,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..8e7caf4bd9acd --- /dev/null +++ b/.github/scripts/test-scope-action.test.mjs @@ -0,0 +1,352 @@ +// 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 = [], + headError, + 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 && headError) { + throw Object.assign(new Error('Server Error'), {status: headError}); + } + 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); + }); + + 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/spec.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, + '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('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'), + 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'), + 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 634fd70c0dc37..4e6982f4d8c53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -845,95 +845,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 != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (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 != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (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 != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (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 != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (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, @@ -1798,107 +1761,11 @@ 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 != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (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 != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true') }} + needs: [build-storybook] + if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.draft && (github.event.pull_request.head.repo.full_name == github.repository || contains(github.event.pull_request.labels.*.name, 'ci:run'))) && (github.event_name != 'workflow_dispatch' && always() && !cancelled()) }} # Frame evidence measures timing: four cores, one Playwright worker. - runs-on: ${{ needs.interaction-scope.outputs.applies == 'true' && 'ubuntu-latest' || 'ubuntu-slim' }} + runs-on: ubuntu-latest timeout-minutes: 15 permissions: # The heavy-suite caps read the author's pull requests and runs. @@ -1906,27 +1773,39 @@ jobs: 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." # The caps step comes from the merge commit, not the pull request's # head: a head that predates the step does not have it. The head # checkout below replaces this directory. - name: Check out the heavy-suite caps - if: needs.interaction-scope.outputs.applies == 'true' + if: steps.scope.outputs.applies == 'true' uses: actions/checkout@v7 with: path: .heavy-caps @@ -1934,45 +1813,45 @@ jobs: persist-credentials: false - name: Per-author heavy-suite caps - if: needs.interaction-scope.outputs.applies == 'true' + if: steps.scope.outputs.applies == 'true' uses: ./.heavy-caps/.github/actions/heavy-caps # The daily heavy-run cap counts this step (spec:AST-030 FR16). - name: Heavy suite runs - if: needs.interaction-scope.outputs.applies == 'true' + if: steps.scope.outputs.applies == 'true' run: 'true' - 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 \ @@ -1981,7 +1860,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 @@ -2708,7 +2587,7 @@ jobs: check-components, test-ui, test-node, - native-swift-scope, + native-swift-runner, test-native-swift, registry-contract, test, @@ -2719,7 +2598,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 cbc1951dffe5b..af737b39e54b3 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/**", ".github/scripts/workflow-paths.test.mjs", ".github/actions/heavy-caps/**", ".github/scripts/ci-pr-gate.*"