mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-09 11:56:15 -08:00
167 lines
15 KiB
JavaScript
167 lines
15 KiB
JavaScript
/* eslint-disable no-template-curly-in-string -- GitHub workflow expressions are literal contract data. */
|
|
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
import { fileURLToPath } from 'node:url'
|
|
import YAML from 'yaml'
|
|
import { requiredJobs } from '../../scripts/ci-summary.mjs'
|
|
|
|
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 })
|
|
assert.deepEqual(workflow.permissions, { contents: 'read' }, 'CI permissions must remain read-only')
|
|
assert.deepEqual(Object.keys(workflow.jobs).sort(), [...requiredJobs, 'ci-result'].sort(), 'Every job must participate in the summary policy')
|
|
const summary = workflow.jobs['ci-result']
|
|
assert.equal(summary.name, 'CI result', 'Keep the required-check name stable')
|
|
assert.equal(summary.if, 'always()', 'Summary must run after failed or cancelled dependencies')
|
|
assert.deepEqual([...summary.needs].sort(), [...requiredJobs].sort(), 'Summary must depend on every required job')
|
|
const gate = summary.steps.find(step => step.run === 'node scripts/ci-summary.mjs')
|
|
assert(gate && !Object.hasOwn(gate, 'if'), 'Final gate must execute unconditionally')
|
|
assert.deepEqual(gate.env, { ARTPLAYER_CI_NEEDS: '${{ toJSON(needs) }}' }, 'Pass results as structured data, not shell interpolation')
|
|
for (const [id, job] of Object.entries(workflow.jobs)) {
|
|
assert(!job['continue-on-error'], 'Jobs cannot allow failure')
|
|
assert(job['timeout-minutes'] > 0 && job['timeout-minutes'] <= 60, 'Every job needs a bounded timeout')
|
|
if (job.permissions)
|
|
assert.deepEqual(job.permissions, { contents: 'read' }, 'Job permissions must remain read-only')
|
|
const checkout = job.steps.find(step => step.uses?.startsWith('actions/checkout@'))
|
|
assert(checkout && !Object.hasOwn(checkout, 'if') && checkout.with?.['fetch-depth'] === 0 && checkout.with['persist-credentials'] === false, 'Every job needs unconditional full-history checkout without saved credentials')
|
|
const setup = job.steps.find(step => step.uses?.startsWith('actions/setup-node@'))
|
|
assert(setup && !Object.hasOwn(setup, 'if') && setup.with?.['node-version-file'] === '.node-version' && setup.with['package-manager-cache'] === false && !setup.with.cache, 'Use the canonical Node and explicit download caches')
|
|
for (const step of job.steps) {
|
|
assert(!step['continue-on-error'], 'Steps cannot hide failures')
|
|
if (step.uses)
|
|
assert(/^[\w.-]+\/[\w./-]+@[a-f0-9]{40}$/.test(step.uses), 'Actions require immutable commit pins')
|
|
}
|
|
const upload = job.steps.find(step => step.uses?.startsWith('actions/upload-artifact@'))
|
|
assert(upload?.if === 'always()' && upload.with?.['include-hidden-files'] === true, 'Always preserve available hidden-cache reports')
|
|
assert(upload.with.path.includes('refactor/.cache/ci/'), 'Every job must preserve source and execution logs')
|
|
for (const part of ['github.run_id', 'github.run_attempt']) assert(upload.with.name.includes(part), 'Artifacts must be distinct across runs and attempts')
|
|
if (id === 'ci-result')
|
|
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, 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.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')
|
|
assert(['${{ steps.context.outputs.yarn_cache }}', '${{ steps.context.outputs.browser_cache }}'].includes(cache.with.path), 'Do not cache node_modules or generated artifacts')
|
|
for (const part of ['runner.os', 'runner.arch', 'steps.context.outputs.node', 'steps.context.outputs.yarn', 'github.ref', 'hashFiles(\'yarn.lock\')'])
|
|
assert(cache.with.key.includes(part), `Cache key must isolate ${part}`)
|
|
if (cache.with.path.includes('browser_cache'))
|
|
assert(cache.with.key.includes('steps.context.outputs.playwright'), 'Browser cache must follow the exact Playwright version')
|
|
}
|
|
const installIndex = job.steps.findIndex(step => step.run?.split('\n').some(line => /^yarn install --frozen-lockfile --non-interactive(?: 2>&1 \| tee refactor\/\.cache\/ci\/install\.log)?$/.test(line)) && !Object.hasOwn(step, 'if'))
|
|
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']
|
|
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 = consumers.steps.findIndex(step => step.id === id)
|
|
assert(index > consumerIndex, 'Build once before each ordered consumer runtime switch')
|
|
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 = consumers.steps[index + 1]
|
|
assert(probe?.run === command && !Object.hasOwn(probe, 'if'), 'Run the installed consumer on the selected Node')
|
|
consumerIndex = index + 1
|
|
}
|
|
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/'],
|
|
['test:ecosystem-types', 'ecosystem-types', 'ecosystem-types/'],
|
|
]) {
|
|
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
|
|
}
|
|
for (const suffix of ['*.json', '*.log'])
|
|
assert(consumerUpload.with.path.split('\n').includes(`refactor/.cache/*-package-types-*/${suffix}`), 'Retain specialized installed type evidence')
|
|
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 --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]) {
|
|
assert.equal(browser.steps[index].if, '${{ !cancelled() }}', 'Both browser scopes run after ordinary failures without masking the failed job')
|
|
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@'))
|
|
assert.equal(pages?.if, 'inputs.pages-artifact && github.ref == \'refs/heads/master\' && matrix.os == \'ubuntu-latest\'', 'Only one trusted matrix leg can prepare Pages')
|
|
const prepareIndex = workflow.jobs.checks.steps.findIndex(step => step.id === 'pages')
|
|
const prepare = workflow.jobs.checks.steps[prepareIndex]
|
|
assert(prepare && prepare.if === pages.if && prepare.run === 'yarn prepare:pages 2>&1 | tee refactor/.cache/ci/pages.log', 'Pages upload needs the matching strict preparation step')
|
|
assert(prepareIndex > workflow.jobs.checks.steps.findIndex(step => step.run?.startsWith('yarn ci:build ')) && prepareIndex < workflow.jobs.checks.steps.indexOf(pages), 'Build, preflight and upload must be ordered')
|
|
assert.equal(pages.with.path, '${{ steps.pages.outputs.site }}', 'Upload the exact validated site, never the working docs tree')
|
|
let previous = prepareIndex
|
|
for (const command of ['yarn test:browser:install --with-deps 2>&1 | tee refactor/.cache/ci/pages-browser-install.log', 'yarn test:pages:browser 2>&1 | tee refactor/.cache/ci/pages-browser.log']) {
|
|
const index = workflow.jobs.checks.steps.findIndex(step => step.run === command)
|
|
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, engines, summary: summary.name }
|
|
}
|
|
|
|
export function validatePagesWorkflow(source) {
|
|
const workflow = YAML.parse(source, { uniqueKeys: true })
|
|
assert.deepEqual(Object.keys(workflow.on), ['workflow_dispatch'], 'Pages needs an explicit trusted dispatch')
|
|
assert.deepEqual(workflow.permissions, { contents: 'read' })
|
|
assert.deepEqual(workflow.concurrency, { 'group': 'pages', 'cancel-in-progress': false })
|
|
assert.deepEqual(Object.keys(workflow.jobs).sort(), ['deploy', 'validate'])
|
|
const { validate, deploy } = workflow.jobs
|
|
for (const job of [validate, deploy]) {
|
|
assert.equal(job.if, 'github.ref == \'refs/heads/master\' && vars.PAGES_DEPLOY_ENABLED == \'true\'')
|
|
assert(!job['continue-on-error'])
|
|
}
|
|
assert.equal(validate.uses, './.github/workflows/nodejs.yml')
|
|
assert.deepEqual(validate.with, { 'pages-artifact': true })
|
|
assert.equal(deploy.needs, 'validate', 'Deploy only after every reusable CI job succeeds')
|
|
assert.deepEqual(deploy.permissions, { 'pages': 'write', 'id-token': 'write' })
|
|
assert.equal(deploy.environment.name, 'github-pages')
|
|
assert.equal(deploy['timeout-minutes'], 10)
|
|
assert.equal(deploy.steps.length, 1, 'Deploy must not execute or rebuild downloaded code')
|
|
assert.match(deploy.steps[0].uses, /^actions\/deploy-pages@[a-f0-9]{40}$/)
|
|
assert(!deploy.steps[0].if && !deploy.steps[0]['continue-on-error'] && !deploy.steps[0].with, 'Use the validated current-run Pages artifact without overrides')
|
|
return { dispatch: 'master', deployment: 'github-pages', enabledBy: 'PAGES_DEPLOY_ENABLED' }
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url))
|
|
console.log(JSON.stringify({ ci: validateCIWorkflow(fs.readFileSync('.github/workflows/nodejs.yml', 'utf8')), pages: validatePagesWorkflow(fs.readFileSync('.github/workflows/pages.yml', 'utf8')) }))
|