mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-17 18:11:20 -08:00
ci: [CI-01] split playback engines from browser consumers
This commit is contained in:
1 parent
92b04d75c0
commit
4a9ef16b16
16 files changed
+1031
-74
No files matched your search
@@ -11,7 +11,9 @@ const systems = {
|
||||
'checks': ['ubuntu-latest', 'windows-latest'],
|
||||
'coverage': ['ubuntu-latest', 'windows-latest'],
|
||||
'browser-smoke': ['ubuntu-latest', 'windows-latest', 'macos-latest'],
|
||||
'browser-consumers': ['ubuntu-latest', 'windows-latest', 'macos-latest'],
|
||||
}
|
||||
const engines = ['chromium', 'firefox', 'webkit']
|
||||
|
||||
export function validateCIWorkflow(source) {
|
||||
const workflow = YAML.parse(source, { uniqueKeys: true })
|
||||
@@ -46,14 +48,14 @@ export function validateCIWorkflow(source) {
|
||||
continue
|
||||
assert(!Object.hasOwn(job, 'if'), 'Required matrix jobs must not be skipped')
|
||||
assert.equal(job['runs-on'], '${{ matrix.os }}')
|
||||
assert.deepEqual(job.strategy.matrix, { os: systems[id] }, 'Do not silently narrow the OS matrix or exclude combinations')
|
||||
assert.deepEqual(job.strategy.matrix, id === 'browser-smoke' ? { os: systems[id], browser: engines } : { os: systems[id] }, 'Do not silently narrow the OS/engine matrix or exclude combinations')
|
||||
assert.equal(job.strategy['fail-fast'], false, 'Do not cancel other matrix evidence after a failure')
|
||||
assert.equal(job.defaults?.run?.shell, 'bash', 'Tee pipelines require the explicit Actions bash pipefail shell')
|
||||
assert(upload.with.name.includes('matrix.os'), 'Matrix artifacts must have distinct names')
|
||||
const contextIndex = job.steps.findIndex(step => step.id === 'context' && step.run === 'node scripts/ci-context.mjs' && !Object.hasOwn(step, 'if'))
|
||||
assert(contextIndex >= 0, 'Cache inputs must come from the checked source')
|
||||
const caches = job.steps.filter(step => step.uses?.startsWith('actions/cache@'))
|
||||
assert.equal(caches.length, id === 'browser-smoke' ? 2 : 1, 'Only explicit Yarn and browser download caches are expected')
|
||||
assert.equal(caches.length, id.startsWith('browser-') ? 2 : 1, 'Only explicit Yarn and browser download caches are expected')
|
||||
for (const cache of caches) {
|
||||
assert(job.steps.indexOf(cache) > contextIndex, 'Record context before restoring caches')
|
||||
assert(!cache.with['restore-keys'] && !cache.with.enableCrossOsArchive, 'Do not restore loosely matched or cross-OS downloads')
|
||||
@@ -67,33 +69,47 @@ export function validateCIWorkflow(source) {
|
||||
assert(installIndex > contextIndex && caches.filter(cache => cache.with.path.includes('yarn_cache')).every(cache => job.steps.indexOf(cache) < installIndex), 'Always perform a frozen install after Yarn cache restore')
|
||||
}
|
||||
const browser = workflow.jobs['browser-smoke']
|
||||
assert(browser.steps.some(step => step.run?.startsWith('yarn test:browser:install --with-deps') && !Object.hasOwn(step, 'if')), 'Browser dependencies must install even on cache hits')
|
||||
let consumerIndex = browser.steps.findIndex(step => step.run?.startsWith('yarn test:package'))
|
||||
const consumers = workflow.jobs['browser-consumers']
|
||||
for (const job of [browser, consumers]) {
|
||||
assert(!Object.hasOwn(job, 'needs'), 'Playback and consumers must collect independent evidence after sibling failures')
|
||||
assert(job.steps.some(step => step.run === 'yarn test:browser:install --with-deps 2>&1 | tee refactor/.cache/ci/browser-install.log' && !Object.hasOwn(step, 'if')), 'Browser dependencies must install even on cache hits')
|
||||
}
|
||||
assert.equal(browser.strategy['max-parallel'], 6, 'Bound the nine playback legs to six concurrent runners')
|
||||
assert.equal(consumers.strategy['max-parallel'], 3, 'Run consumers once per OS without engine duplication')
|
||||
assert.equal(browser.steps.filter(step => step.uses?.startsWith('actions/setup-node@')).length, 1, 'Playback jobs retain the canonical runtime throughout')
|
||||
let consumerIndex = consumers.steps.findIndex(step => step.run?.startsWith('yarn test:package 2>&1 | tee refactor/.cache/ci/package.log\n'))
|
||||
assert(consumerIndex >= 0 && !Object.hasOwn(consumers.steps[consumerIndex], 'if') && consumers.steps[consumerIndex].run.includes('ARTPLAYER_BROWSER_ARTIFACTS=') && consumers.steps[consumerIndex].run.includes('GITHUB_ENV'), 'Prepare and select the core/chapter consumer artifacts')
|
||||
for (const [id, version, command] of [
|
||||
['consumer-node-20', '20.19.0', 'node scripts/package-runtime.mjs --expected-node 20.19.0 2>&1 | tee refactor/.cache/ci/consumer-node-20.log'],
|
||||
['consumer-node-22', '22.12.0', 'node scripts/package-runtime.mjs --expected-node 22.12.0 2>&1 | tee refactor/.cache/ci/consumer-node-22.log'],
|
||||
['restore-canonical-node', null, 'node scripts/package-runtime.mjs --canonical 2>&1 | tee refactor/.cache/ci/consumer-node-canonical.log'],
|
||||
]) {
|
||||
const index = browser.steps.findIndex(step => step.id === id)
|
||||
const index = consumers.steps.findIndex(step => step.id === id)
|
||||
assert(index > consumerIndex, 'Build once before each ordered consumer runtime switch')
|
||||
const step = browser.steps[index]
|
||||
const step = consumers.steps[index]
|
||||
assert(step.uses?.startsWith('actions/setup-node@') && !Object.hasOwn(step, 'if'), 'Consumer Node setup cannot be skipped')
|
||||
assert.deepEqual(step.with, version ? { 'node-version': version, 'package-manager-cache': false } : { 'node-version-file': '.node-version', 'package-manager-cache': false }, 'Use exact consumer versions and restore the canonical browser runtime')
|
||||
const probe = browser.steps[index + 1]
|
||||
const probe = consumers.steps[index + 1]
|
||||
assert(probe?.run === command && !Object.hasOwn(probe, 'if'), 'Run the installed consumer on the selected Node')
|
||||
consumerIndex = index + 1
|
||||
}
|
||||
assert(browser.steps.findIndex(step => step.run?.startsWith('yarn test:browser:source ')) > consumerIndex, 'Browser tools must run after canonical Node restoration')
|
||||
const reactIndex = browser.steps.findIndex(step => step.run === 'yarn test:react-consumer 2>&1 | tee refactor/.cache/ci/react-consumer.log')
|
||||
assert(reactIndex > consumerIndex && !Object.hasOwn(browser.steps[reactIndex], 'if'), 'Run React installed consumers after restoring canonical Node')
|
||||
assert(browser.steps.some(step => step.uses?.startsWith('actions/upload-artifact@') && step.if === 'always()' && step.with.path.split('\n').includes('refactor/.cache/react-consumer-*/')), 'Retain React consumer failure evidence')
|
||||
const vueIndex = browser.steps.findIndex(step => step.run === 'yarn test:vue-consumer 2>&1 | tee refactor/.cache/ci/vue-consumer.log')
|
||||
assert(vueIndex > consumerIndex && !Object.hasOwn(browser.steps[vueIndex], 'if'), 'Run Vue installed consumers after restoring canonical Node')
|
||||
assert(browser.steps.some(step => step.uses?.startsWith('actions/upload-artifact@') && step.if === 'always()' && step.with.path.split('\n').includes('refactor/.cache/vue-consumer-*/')), 'Retain Vue consumer failure evidence')
|
||||
const consumerUpload = consumers.steps.find(step => step.uses?.startsWith('actions/upload-artifact@'))
|
||||
for (const [command, log, directory] of [
|
||||
['test:react-consumer', 'react-consumer', 'react-consumer-*/'],
|
||||
['test:vue-consumer', 'vue-consumer', 'vue-consumer-*/'],
|
||||
['test:iframe-history', 'iframe-history', 'iframe-history/'],
|
||||
['test:performance', 'performance', 'performance/'],
|
||||
]) {
|
||||
const index = consumers.steps.findIndex(step => step.run === `yarn ${command} 2>&1 | tee refactor/.cache/ci/${log}.log`)
|
||||
assert(index > consumerIndex && !Object.hasOwn(consumers.steps[index], 'if'), 'Run complete consumers after restoring canonical Node')
|
||||
assert(consumerUpload.with.path.split('\n').includes(`refactor/.cache/${directory}`), 'Retain consumer failure evidence')
|
||||
assert(!browser.steps.some(step => step.run?.includes(`yarn ${command}`)), 'Do not repeat all-engine consumers in each playback leg')
|
||||
consumerIndex = index
|
||||
}
|
||||
const extraIndex = browser.steps.findIndex(step => step.run?.startsWith('yarn test:package --browser '))
|
||||
const sourceEngineIndex = browser.steps.findIndex(step => step.run === 'yarn test:browser:source 2>&1 | tee refactor/.cache/ci/browser-source.log')
|
||||
const engineIndex = browser.steps.findIndex(step => step.run === 'yarn test:browser:installed 2>&1 | tee refactor/.cache/ci/browser-installed.log')
|
||||
assert(extraIndex > Math.max(consumerIndex, reactIndex, vueIndex) && extraIndex < engineIndex, 'Prepare the shared installed browser roster after consumer probes and before browser checks')
|
||||
const sourceEngineIndex = browser.steps.findIndex(step => step.run === 'yarn test:browser:source --project=${{ matrix.browser }} 2>&1 | tee refactor/.cache/ci/browser-source.log')
|
||||
const engineIndex = browser.steps.findIndex(step => step.run === 'yarn test:browser:installed --project=${{ matrix.browser }} 2>&1 | tee refactor/.cache/ci/browser-installed.log')
|
||||
assert(extraIndex > browser.steps.findIndex(step => step.run?.startsWith('yarn test:browser:install ')) && extraIndex < engineIndex, 'Prepare the shared installed browser roster in every engine job before browser checks')
|
||||
assert(!Object.hasOwn(browser.steps[extraIndex], 'if') && browser.steps[extraIndex].run.includes('GITHUB_ENV') && browser.steps[extraIndex].run.includes('ARTPLAYER_BROWSER_ARTIFACTS='), 'Always select the additional installed browser artifact map')
|
||||
assert(sourceEngineIndex > extraIndex && sourceEngineIndex < engineIndex, 'Retain complete source checks before the additional installed suite')
|
||||
for (const index of [sourceEngineIndex, engineIndex]) {
|
||||
@@ -101,6 +117,7 @@ export function validateCIWorkflow(source) {
|
||||
assert(!browser.steps[index]['continue-on-error'], 'Browser scope failures must fail CI')
|
||||
}
|
||||
const browserUpload = browser.steps.find(step => step.uses?.startsWith('actions/upload-artifact@'))
|
||||
assert(browserUpload.with.name.includes('matrix.browser'), 'Browser artifacts must be distinct across engines')
|
||||
for (const directory of ['refactor/.cache/browser-source/', 'refactor/.cache/browser-installed/'])
|
||||
assert(browserUpload.if === 'always()' && browserUpload.with.path.split('\n').includes(directory), 'Retain independent source and installed reports even on failure')
|
||||
const pages = workflow.jobs.checks.steps.find(step => step.uses?.startsWith('actions/upload-pages-artifact@'))
|
||||
@@ -116,7 +133,7 @@ export function validateCIWorkflow(source) {
|
||||
assert(index > previous && index < workflow.jobs.checks.steps.indexOf(pages) && workflow.jobs.checks.steps[index].if === pages.if, 'Staged entrypoints require ordered browser installation and verification before upload')
|
||||
previous = index
|
||||
}
|
||||
return { jobs: requiredJobs, systems, summary: summary.name }
|
||||
return { jobs: requiredJobs, systems, engines, summary: summary.name }
|
||||
}
|
||||
|
||||
export function validatePagesWorkflow(source) {
|
||||
|
||||
@@ -9,6 +9,12 @@ import { validateCIWorkflow } from './ci-workflow.mjs'
|
||||
const source = fs.readFileSync('.github/workflows/nodejs.yml', 'utf8')
|
||||
test('CI workflow preserves complete matrix jobs, pinned download caches and an unconditional summary', () => {
|
||||
assert.equal(validateCIWorkflow(source).summary, 'CI result')
|
||||
const { jobs } = YAML.parse(source)
|
||||
const matrix = jobs['browser-smoke'].strategy.matrix
|
||||
const legs = matrix.os.flatMap(os => matrix.browser.map(browser => `${os}/${browser}`))
|
||||
assert.deepEqual(legs, ['ubuntu-latest', 'windows-latest', 'macos-latest'].flatMap(os => ['chromium', 'firefox', 'webkit'].map(browser => `${os}/${browser}`)))
|
||||
assert.equal(new Set(legs).size, 9)
|
||||
assert.deepEqual(jobs['browser-consumers'].strategy.matrix, { os: ['ubuntu-latest', 'windows-latest', 'macos-latest'] })
|
||||
})
|
||||
|
||||
for (const [name, mutate] of [
|
||||
@@ -19,6 +25,13 @@ for (const [name, mutate] of [
|
||||
['untracked extra job', w => w.jobs.extra = w.jobs.coverage],
|
||||
['matrix exclusion', w => w.jobs.checks.strategy.matrix.exclude = [{ os: 'windows-latest' }]],
|
||||
['removed Windows build', w => w.jobs.checks.strategy.matrix.os.pop()],
|
||||
['removed playback engine', w => w.jobs['browser-smoke'].strategy.matrix.browser.pop()],
|
||||
['engine matrix exclusion', w => w.jobs['browser-smoke'].strategy.matrix.exclude = [{ os: 'macos-latest', browser: 'webkit' }]],
|
||||
['consumer engine duplication', w => w.jobs['browser-consumers'].strategy.matrix.browser = ['chromium', 'firefox', 'webkit']],
|
||||
['browser waits on consumer success', w => w.jobs['browser-smoke'].needs = 'browser-consumers'],
|
||||
['consumers wait on browser success', w => w.jobs['browser-consumers'].needs = 'browser-smoke'],
|
||||
['unbounded playback concurrency', w => delete w.jobs['browser-smoke'].strategy['max-parallel']],
|
||||
['browser artifact collision', w => w.jobs['browser-smoke'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.name = 'browser-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}'],
|
||||
['fail-fast cancellation', w => w.jobs.coverage.strategy['fail-fast'] = true],
|
||||
['artifact name collision', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.name = 'checks'],
|
||||
['write permissions', w => w.permissions.contents = 'write'],
|
||||
@@ -31,8 +44,8 @@ for (const [name, mutate] of [
|
||||
['conditional frozen install', w => w.jobs.checks.steps.find(s => s.run?.includes('yarn install --frozen-lockfile')).if = 'false'],
|
||||
['commented frozen install', w => w.jobs.checks.steps.find(s => s.run?.includes('yarn install --frozen-lockfile')).run = '# yarn install --frozen-lockfile --non-interactive'],
|
||||
['conditional Node setup', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/setup-node@')).if = 'false'],
|
||||
['wrong consumer Node version', w => w.jobs['browser-smoke'].steps.find(s => s.id === 'consumer-node-20').with['node-version'] = '24'],
|
||||
['skipped installed consumer', w => w.jobs['browser-smoke'].steps.find(s => s.run?.includes('--expected-node 22.12.0')).if = 'false'],
|
||||
['wrong consumer Node version', w => w.jobs['browser-consumers'].steps.find(s => s.id === 'consumer-node-20').with['node-version'] = '24'],
|
||||
['skipped installed consumer', w => w.jobs['browser-consumers'].steps.find(s => s.run?.includes('--expected-node 22.12.0')).if = 'false'],
|
||||
['missing browser plugin installation', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:package --browser')).run = 'echo omitted'],
|
||||
['manual partial browser package roster', (w) => {
|
||||
const step = w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:package --browser'))
|
||||
@@ -41,20 +54,35 @@ for (const [name, mutate] of [
|
||||
['skipped browser plugin installation', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:package --browser')).if = 'false'],
|
||||
['missing source browser checks', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:browser:source ')).run = 'echo omitted'],
|
||||
['filtered source browser checks', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:browser:source ')).run += ' --grep subset'],
|
||||
['fixed engine silently repeated', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:browser:source ')).run = 'yarn test:browser:source --project=chromium 2>&1 | tee refactor/.cache/ci/browser-source.log'],
|
||||
['installed engine selector removed', (w) => {
|
||||
const step = w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:browser:installed '))
|
||||
step.run = step.run.replace(' --project=${{ matrix.browser }}', '')
|
||||
}],
|
||||
['source failure prevents installed evidence', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:browser:installed ')).if = 'success()'],
|
||||
['ignored installed failure', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:browser:installed '))['continue-on-error'] = true],
|
||||
['missing separate source evidence', (w) => {
|
||||
const upload = w.jobs['browser-smoke'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@'))
|
||||
upload.with.path = upload.with.path.replace('refactor/.cache/browser-source/\n', '')
|
||||
}],
|
||||
['browser runtime not restored', w => w.jobs['browser-smoke'].steps.find(s => s.id === 'restore-canonical-node').with = { 'node-version': '22.12.0', 'package-manager-cache': false }],
|
||||
['skipped React consumer', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:react-consumer')).if = 'false'],
|
||||
['removed React consumer', w => w.jobs['browser-smoke'].steps = w.jobs['browser-smoke'].steps.filter(s => !s.run?.startsWith('yarn test:react-consumer'))],
|
||||
['missing React evidence', w => w.jobs['browser-smoke'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.path = 'refactor/.cache/ci/'],
|
||||
['skipped Vue consumer', w => w.jobs['browser-smoke'].steps.find(s => s.run?.startsWith('yarn test:vue-consumer')).if = 'false'],
|
||||
['removed Vue consumer', w => w.jobs['browser-smoke'].steps = w.jobs['browser-smoke'].steps.filter(s => !s.run?.startsWith('yarn test:vue-consumer'))],
|
||||
['missing Vue evidence', w => w.jobs['browser-smoke'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.path = w.jobs['browser-smoke'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.path.replace('refactor/.cache/vue-consumer-*/', '')],
|
||||
['browser runtime not restored', w => w.jobs['browser-consumers'].steps.find(s => s.id === 'restore-canonical-node').with = { 'node-version': '22.12.0', 'package-manager-cache': false }],
|
||||
['skipped React consumer', w => w.jobs['browser-consumers'].steps.find(s => s.run?.startsWith('yarn test:react-consumer')).if = 'false'],
|
||||
['removed React consumer', w => w.jobs['browser-consumers'].steps = w.jobs['browser-consumers'].steps.filter(s => !s.run?.startsWith('yarn test:react-consumer'))],
|
||||
['missing React evidence', w => w.jobs['browser-consumers'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.path = 'refactor/.cache/ci/'],
|
||||
['skipped Vue consumer', w => w.jobs['browser-consumers'].steps.find(s => s.run?.startsWith('yarn test:vue-consumer')).if = 'false'],
|
||||
['removed Vue consumer', w => w.jobs['browser-consumers'].steps = w.jobs['browser-consumers'].steps.filter(s => !s.run?.startsWith('yarn test:vue-consumer'))],
|
||||
['missing Vue evidence', w => w.jobs['browser-consumers'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.path = w.jobs['browser-consumers'].steps.find(s => s.uses?.startsWith('actions/upload-artifact@')).with.path.replace('refactor/.cache/vue-consumer-*/', '')],
|
||||
['Pages uploaded by both matrix legs', w => w.jobs.checks.steps.find(s => s.uses?.startsWith('actions/upload-pages-artifact@')).if = 'inputs.pages-artifact && github.ref == \'refs/heads/master\''],
|
||||
['missing iframe history gate', w => w.jobs['browser-consumers'].steps = w.jobs['browser-consumers'].steps.filter(s => !s.run?.startsWith('yarn test:iframe-history'))],
|
||||
['performance narrowed to one engine', (w) => {
|
||||
const step = w.jobs['browser-consumers'].steps.find(s => s.run?.startsWith('yarn test:performance'))
|
||||
step.run = step.run.replace('test:performance', 'test:performance --project=chromium')
|
||||
}],
|
||||
['browser consumer cache-hit skips dependencies', w => w.jobs['browser-consumers'].steps.find(s => s.run?.startsWith('yarn test:browser:install')).if = 'false'],
|
||||
['missing independent consumer artifact selection', (w) => {
|
||||
const step = w.jobs['browser-consumers'].steps.find(s => s.run?.startsWith('yarn test:package '))
|
||||
step.run = step.run.split('\n')[0]
|
||||
}],
|
||||
]) {
|
||||
test(`CI rejects ${name}`, () => {
|
||||
const workflow = YAML.parse(source)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-template-curly-in-string -- GitHub matrix expressions are literal workflow policy. */
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
@@ -69,7 +70,7 @@ export function analyzeImpact(model, changedFiles) {
|
||||
requiredChecks: model.policy.gates,
|
||||
requiredCIJobs: [...new Set(model.policy.gates.map(gate => gate.job))].sort(),
|
||||
installedConsumerGaps: affectedPackages.filter(name => name !== model.policy.site && !model.policy.installedConsumerPackages.includes(name)),
|
||||
limitation: 'CI gates remain unconditional. This dependency report selects no tests for omission and certifies neither missing package suites nor device/vendor acceptance.',
|
||||
limitation: 'CI jobs remain required; both browser scopes also run after ordinary step failures. This dependency report selects no tests for omission and certifies neither missing package suites nor device/vendor acceptance.',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,9 +106,18 @@ export function readImpactModel(directory) {
|
||||
const read = file => JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'))
|
||||
const policy = read('refactor/impact-policy.json')
|
||||
assert.equal(policy.schemaVersion, 1)
|
||||
const gateCommands = policy.gates.map(gate => gate.command)
|
||||
const gateCommands = policy.gates.map(gate => [gate.job, gate.command, ...(gate.arguments || [])].join(' '))
|
||||
assert.equal(new Set(gateCommands).size, gateCommands.length, 'Duplicate impact gate')
|
||||
for (const command of ['yarn ci:check', 'yarn ci:build', 'yarn test:package', 'yarn test:browser', 'yarn test:coverage', 'yarn test:performance'])
|
||||
for (const command of [
|
||||
'checks yarn ci:check',
|
||||
'checks yarn ci:build',
|
||||
'browser-consumers yarn test:package',
|
||||
'browser-smoke yarn test:package --browser',
|
||||
'browser-smoke yarn test:browser:source --project=${{ matrix.browser }}',
|
||||
'browser-smoke yarn test:browser:installed --project=${{ matrix.browser }}',
|
||||
'coverage yarn test:coverage',
|
||||
'browser-consumers yarn test:performance',
|
||||
])
|
||||
assert(gateCommands.includes(command), `Missing mandatory ecosystem gate: ${command}`)
|
||||
const names = policy.packages.map(pkg => pkg.name).sort()
|
||||
assert.equal(new Set(names).size, names.length, 'Duplicate mapped package')
|
||||
@@ -188,6 +198,10 @@ export function readImpactModel(directory) {
|
||||
for (const gate of policy.gates) {
|
||||
assert(/^yarn [a-z][\w:-]*$/.test(gate.command), 'Impact gates require an explicit repository script')
|
||||
assert(Object.hasOwn(scripts, gate.command.slice(5)), `Missing required command: ${gate.command}`)
|
||||
if (gate.arguments !== undefined)
|
||||
assert(Array.isArray(gate.arguments) && gate.arguments.length === 1 && ['--browser', '--project=${{ matrix.browser }}'].includes(gate.arguments[0]), 'Impact gate arguments must use a reviewed complete browser scope')
|
||||
if (gate.condition !== undefined)
|
||||
assert(gate.condition === '${{ !cancelled() }}' && ['yarn test:browser:source', 'yarn test:browser:installed'].includes(gate.command), 'Only browser scopes may collect evidence after ordinary failures')
|
||||
}
|
||||
const consumerSource = ts.createSourceFile('package-consumer.mjs', fs.readFileSync(path.join(directory, 'scripts/package-consumer.mjs'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
const consumerNames = consumerSource.statements.filter(ts.isVariableStatement).flatMap(statement => [...statement.declarationList.declarations]).filter(node => ts.isIdentifier(node.name) && node.name.text === 'names')
|
||||
@@ -213,17 +227,18 @@ export function validateImpactWorkflow(text, model) {
|
||||
const job = workflow.jobs?.[gate.job]
|
||||
assert(job && !Object.hasOwn(job, 'if') && !job['continue-on-error'], `Required impact job must not be conditional or allowed to fail: ${gate.job}`)
|
||||
const steps = job.steps || []
|
||||
const command = gate.command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const invocation = [gate.command, ...(gate.arguments || [])].join(' ')
|
||||
const command = invocation.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
assert(steps.some((step) => {
|
||||
if (Object.hasOwn(step, 'if') || step['continue-on-error'] || typeof step.run !== 'string')
|
||||
if ((gate.condition ? step.if !== gate.condition : Object.hasOwn(step, 'if')) || step['continue-on-error'] || typeof step.run !== 'string')
|
||||
return false
|
||||
const shell = step.shell || job.defaults?.run?.shell || workflow.defaults?.run?.shell
|
||||
const defaultBash = !shell && /^(?:ubuntu|macos)-/.test(job['runs-on'])
|
||||
if (shell !== 'bash' && !defaultBash)
|
||||
return false
|
||||
const first = step.run.split(/\r?\n/).map(line => line.trim()).find(line => line && !line.startsWith('#'))
|
||||
return first === gate.command || (shell === 'bash' && new RegExp(`^${command} 2>&1 \\| tee refactor/\\.cache/ci/[a-z-]+\\.log$`).test(first))
|
||||
}), `Required unconditional command missing from ${gate.job}: ${gate.command}`)
|
||||
return first === invocation || (shell === 'bash' && new RegExp(`^${command} 2>&1 \\| tee refactor/\\.cache/ci/[a-z-]+\\.log$`).test(first))
|
||||
}), `Required command or execution condition missing from ${gate.job}: ${invocation}`)
|
||||
assert(steps.some(step => step.uses?.startsWith('actions/checkout@') && !Object.hasOwn(step, 'if') && !step['continue-on-error'] && step.with?.['fetch-depth'] === 0), `Impact and compatibility checks require full history: ${gate.job}`)
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,23 @@ test('Policy cannot omit core consumers or claim stale installed-package coverag
|
||||
assert.throws(repo.model, /scope extraction/)
|
||||
})
|
||||
|
||||
test('Each split CI invocation remains mandatory even when its sibling uses the same script', (t) => {
|
||||
const repo = fixture(t)
|
||||
const gates = structuredClone(repo.policy.gates)
|
||||
for (let index = 0; index < gates.length; index++) {
|
||||
repo.policy.gates = gates.filter((gate, candidate) => candidate !== index)
|
||||
repo.write('refactor/impact-policy.json', repo.policy)
|
||||
assert.throws(repo.model, /Missing mandatory ecosystem gate/)
|
||||
}
|
||||
repo.policy.gates = [...gates, gates[0]]
|
||||
repo.write('refactor/impact-policy.json', repo.policy)
|
||||
assert.throws(repo.model, /Duplicate impact gate/)
|
||||
repo.policy.gates = structuredClone(gates)
|
||||
repo.policy.gates.find(gate => gate.command === 'yarn test:browser:installed').condition = 'success()'
|
||||
repo.write('refactor/impact-policy.json', repo.policy)
|
||||
assert.throws(repo.model, /Only browser scopes/)
|
||||
})
|
||||
|
||||
test('Shared/unknown files and computed imports conservatively expand; unsafe or unmapped package paths are rejected', (t) => {
|
||||
for (const file of ['yarn.lock', 'scripts/new-build-stage.mjs', 'types/new-boundary.d.ts'])
|
||||
assert.equal(analyzeImpact(actual, [file]).affectedPackages.length, 22)
|
||||
@@ -127,6 +144,10 @@ test('CI workflow cannot silently skip or soften required impact gates', () => {
|
||||
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).if = false },
|
||||
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.uses?.startsWith('actions/checkout@')).if = false },
|
||||
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.uses?.startsWith('actions/checkout@')).with['fetch-depth'] = 1 },
|
||||
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.run?.startsWith('yarn test:browser:source ')).if = 'success()' },
|
||||
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.run?.startsWith('yarn test:browser:installed ')).run += ' --grep subset' },
|
||||
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.run?.startsWith('yarn test:package --browser')).run = 'yarn test:package' },
|
||||
(workflow) => { workflow.jobs['browser-consumers'].steps.find(step => step.run?.startsWith('yarn test:performance ')).run += ' || true' },
|
||||
]) {
|
||||
const workflow = YAML.parse(source)
|
||||
edit(workflow)
|
||||
|
||||
Reference in new issue
Block a user