diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d48e47d..846e6ee7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,18 +2,8 @@ name: CI on: pull_request: - paths-ignore: - - 'docs/**' - - '*.md' - - 'LICENSE*' - - 'NOTICE*' push: branches: [main] - paths-ignore: - - 'docs/**' - - '*.md' - - 'LICENSE*' - - 'NOTICE*' permissions: contents: read @@ -28,11 +18,31 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + fetch-depth: 0 + - name: Classify the change before the full build + id: scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + if ! [[ "$BASE_SHA" =~ ^[0-9a-f]{40}$ && \ + "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || \ + [ "$BASE_SHA" = '0000000000000000000000000000000000000000' ]; then + printf 'full_build=true\n' >> "$GITHUB_OUTPUT" + echo 'The comparison range is incomplete. The full build will run.' + exit 0 + fi + git diff --name-only "$BASE_SHA" "$HEAD_SHA" | \ + node scripts/classify-ci-scope.mjs + - if: steps.scope.outputs.full_build == 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc cache: npm - name: Build and run browser contract + if: steps.scope.outputs.full_build == 'true' uses: cypress-io/github-action@fa4a118725a8f001170d49631ea89e5d66fee626 # v7.4.1 env: # GitHub data is fetched server-side in getStaticProps. The @@ -45,3 +55,6 @@ jobs: wait-on: http://127.0.0.1:3000 wait-on-timeout: 120 config: baseUrl=http://127.0.0.1:3000 + - name: Record a documentation-only pass + if: steps.scope.outputs.full_build == 'false' + run: echo 'Only documentation or notice files changed. The required check reported without starting the browser build.' diff --git a/scripts/classify-ci-scope.mjs b/scripts/classify-ci-scope.mjs new file mode 100644 index 00000000..58197a94 --- /dev/null +++ b/scripts/classify-ci-scope.mjs @@ -0,0 +1,29 @@ +import { appendFileSync, readFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' + +const cheapPath = /^(?:docs\/.*|.*\.md|LICENSE(?:\..*)?|NOTICE(?:\..*)?)$/ + +export function requiresFullBuild(paths) { + if (paths.length === 0) return true + return paths.some((path) => !cheapPath.test(path)) +} + +function main() { + const paths = readFileSync(0, 'utf8') + .split('\n') + .map((path) => path.trim()) + .filter(Boolean) + const fullBuild = requiresFullBuild(paths) + const output = process.env.GITHUB_OUTPUT + if (!output) throw new Error('GITHUB_OUTPUT is required') + appendFileSync(output, `full_build=${fullBuild}\n`) + console.log( + fullBuild + ? 'A product, workflow, or build file changed. The full build will run.' + : 'Only documentation or notice files changed. The full build can be skipped.' + ) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main() +} diff --git a/tests/ciWorkflowContract.test.js b/tests/ciWorkflowContract.test.js new file mode 100644 index 00000000..87bd7879 --- /dev/null +++ b/tests/ciWorkflowContract.test.js @@ -0,0 +1,41 @@ +const fs = require('node:fs') +const path = require('node:path') +const test = require('node:test') +const assert = require('node:assert/strict') + +const root = path.resolve(__dirname, '..') +const workflow = fs.readFileSync( + path.join(root, '.github/workflows/ci.yml'), + 'utf8' +) + +test('build-and-e2e reports on every pull request', () => { + const pullRequest = workflow + .split(' pull_request:', 2)[1] + .split(' push:', 1)[0] + assert.doesNotMatch(pullRequest, /paths(?:-ignore)?:/) + assert.match(workflow, /^ build-and-e2e:$/m) +}) + +test('the full browser contract stays behind a fail-closed classifier', () => { + assert.match(workflow, /fetch-depth: 0/) + assert.match(workflow, /git diff --name-only "\$BASE_SHA" "\$HEAD_SHA"/) + assert.match(workflow, /node scripts\/classify-ci-scope\.mjs/) + assert.match( + workflow, + /if: steps\.scope\.outputs\.full_build == 'true'\n\s+uses: cypress-io\/github-action@/ + ) + assert.match(workflow, /BASE_SHA.*0000000000000000000000000000000000000000/s) + assert.match(workflow, /printf 'full_build=true\\n'/) +}) + +test('the classifier skips only documentation and notice files', async () => { + const { requiresFullBuild } = await import( + '../scripts/classify-ci-scope.mjs' + ) + assert.equal(requiresFullBuild(['docs/operator.md', 'README.md']), false) + assert.equal(requiresFullBuild(['LICENSE', 'NOTICE.txt']), false) + assert.equal(requiresFullBuild(['docs/operator.md', 'components/Hero.js']), true) + assert.equal(requiresFullBuild(['.github/workflows/ci.yml']), true) + assert.equal(requiresFullBuild([]), true) +})