From 03044029c7eec62fc58b86f1805a24e8ffe24f8e Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:24:52 +0100 Subject: [PATCH 1/3] perf(ci): stage expensive checks behind cheap prerequisite failures Apply only reviewed needs additions; preserve all other workflow bytes. Require frontend success before E2E without serialising API behind the full backend unit platform matrix. Keep security scans independent. Refs #2327, #2332 --- .github/workflows/ci-required.yml | 16 ++++++++ docs/ci/continuation/STAGING.md | 37 +++++++++++++++++++ scripts/ci/smart-ci/continuation.test.mjs | 1 + .../continuation/tests/workflow.test.mjs | 24 ++++++++++++ 4 files changed, 78 insertions(+) create mode 100644 docs/ci/continuation/STAGING.md create mode 100644 scripts/ci/smart-ci/continuation/tests/workflow.test.mjs diff --git a/.github/workflows/ci-required.yml b/.github/workflows/ci-required.yml index f056d76a0..08cd50f16 100644 --- a/.github/workflows/ci-required.yml +++ b/.github/workflows/ci-required.yml @@ -151,24 +151,35 @@ jobs: dotnet-version: 8.0.x backend-unit: + needs: + - backend-architecture + - release-workflow-contract name: Backend Unit uses: ./.github/workflows/reusable-backend-unit.yml with: dotnet-version: 8.0.x api-integration: + needs: + - backend-architecture + - release-workflow-contract name: API Integration uses: ./.github/workflows/reusable-api-integration.yml with: dotnet-version: 8.0.x migration-validation: + needs: + - backend-architecture name: Migration Validation uses: ./.github/workflows/reusable-migration-validation.yml with: dotnet-version: 8.0.x frontend-unit: + needs: + - release-workflow-contract + - paper-color-audit name: Frontend Unit uses: ./.github/workflows/reusable-frontend-unit.yml with: @@ -179,6 +190,8 @@ jobs: uses: ./.github/workflows/reusable-paper-color-audit.yml container-images: + needs: + - release-workflow-contract name: Container Images uses: ./.github/workflows/reusable-container-images.yml with: @@ -245,6 +258,9 @@ jobs: - backend-unit - api-integration - migration-validation + - frontend-unit + - release-workflow-contract + - paper-color-audit uses: ./.github/workflows/reusable-e2e-smoke.yml with: dotnet-version: 8.0.x diff --git a/docs/ci/continuation/STAGING.md b/docs/ci/continuation/STAGING.md new file mode 100644 index 000000000..3e7a41b32 --- /dev/null +++ b/docs/ci/continuation/STAGING.md @@ -0,0 +1,37 @@ +# Required-workflow staging + +Date: 2026-09-10. Owners: #2327 and #2332. Parent: [engineering contract](README.md). + +This slice applies the `minimal` transform to `.github/workflows/ci-required.yml`. It changes scheduling dependencies only. It does not select fewer checks on a healthy candidate, remove Windows coverage, alter security enforcement, change permissions, or reuse earlier test results. + +| Work | New prerequisite | +| --- | --- | +| Backend Unit and API Integration | Backend Architecture and Release Workflow Contract | +| Migration Validation | Backend Architecture | +| Frontend Unit | Release Workflow Contract and Paper Color Audit | +| Container Images | Release Workflow Contract | +| E2E Smoke | Frontend Unit, Release Workflow Contract and Paper Color Audit, in addition to every existing dependency | + +The short control checks can prevent launching dependent expensive work after a known failure. The API job deliberately does not wait for the entire Backend Unit matrix: the `compute` proposal is optional, not adopted here. Security jobs remain independent. In particular, Secret Scan only runs on PR events and must not become an unconditional dependency on push/merge-group events. + +The original workflow was read at immutable base `6c51b09bcdcefbc7a852a7047aa9ab8ee5eb10b7`. Its source blob `f056d76a0c1b4ecabd95783d47131dfe096121c5` was reproduced byte-for-byte before transformation. The transform checks that removing `needs` from each changed job leaves identical bytes. It preserves the complete header/comments, existing check names, reusable callees, commands, action pins, matrices, event/concurrency rules and permissions. The historical header's advisory/enforcing shorthand is deliberately not edited by a dependency-only PR; executable settings remain the authority. + +## Qualification + +Run the checked-in topology regressions through the existing self-test bridge: + +```sh +node --test scripts/ci/smart-ci/continuation.test.mjs +``` + +Local result for this slice: **257 passed, 0 failed/skipped/cancelled**, Node 22.16.0/Linux. Four regressions exercise the actual checked-in workflow; the existing transformer suite covers unknown shapes, cycles, duplicate dependencies, idempotence and preservation. Local PyYAML parsing is only a syntax sanity check, not GitHub workflow semantic validation. + +Configured-Node hosted Smart CI, Actionlint, full required CI and independent/maintainer reviews remain required on this PR head. Do not equate a successful core fixture with product qualification. No cancellation API is called: the transform prevents dependent jobs from starting after failed prerequisites but cannot stop work already running. + +## Measurement and rollback + +Compare matched change/risk strata and include failed/cancelled attempts, not just green runs. Record total runner seconds and healthy-candidate latency separately; a dependency barrier can reduce failed-run cost while increasing green latency. Include queue/setup time and collector overhead when available; do not fill missing data with zeros. + +The retained August baseline is historical and is not an after-measurement. Capture a fresh window using the existing estate measurement tool only after the topology is deployed. No numerical savings are claimed in this PR. + +Rollback is the inverse of these `needs` additions. Keep every newer concurrent hardening change. Do not restore an entire old workflow file over subsequent edits. The canonical policy remains shadow and all human settings/review gates remain unchanged. diff --git a/scripts/ci/smart-ci/continuation.test.mjs b/scripts/ci/smart-ci/continuation.test.mjs index 86da78294..a3ccb1c14 100644 --- a/scripts/ci/smart-ci/continuation.test.mjs +++ b/scripts/ci/smart-ci/continuation.test.mjs @@ -6,3 +6,4 @@ import './continuation/tests/execution.test.mjs'; import './continuation/tests/planner.test.mjs'; import './continuation/tests/adapter.test.mjs'; import './continuation/tests/repository.test.mjs'; +import './continuation/tests/workflow.test.mjs'; diff --git a/scripts/ci/smart-ci/continuation/tests/workflow.test.mjs b/scripts/ci/smart-ci/continuation/tests/workflow.test.mjs new file mode 100644 index 000000000..392ceaf18 --- /dev/null +++ b/scripts/ci/smart-ci/continuation/tests/workflow.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { stageWorkflow } from '../tools/stage-taskdeck.mjs'; + +const workflow = () => readFileSync(new URL('../../../../../.github/workflows/ci-required.yml', import.meta.url), 'utf8'); +function block(text, id) { + const start = text.indexOf(`\n ${id}:\n`); assert.ok(start >= 0, `missing ${id}`); + return text.slice(start + 1).split(/\n [a-z][a-z0-9-]*:\n/)[0]; +} +test('checked-in required workflow satisfies minimal dependency staging', () => { + const text = workflow(), staged = stageWorkflow(text); assert.deepEqual(staged.changes, []); assert.equal(staged.text, text); +}); +test('E2E waits for frontend and existing backend prerequisites', () => { + const b = block(workflow(), 'e2e-smoke'); + for (const id of ['frontend-unit', 'backend-unit', 'api-integration', 'migration-validation', 'backend-architecture', 'docs-governance']) assert.ok(b.includes(` - ${id}\n`)); +}); +test('API does not wait for the full backend unit platform matrix', () => { + const b = block(workflow(), 'api-integration'); assert.ok(b.includes(' - backend-architecture\n')); assert.ok(!b.includes(' - backend-unit\n')); +}); +test('PR-only secret scan remains independent of push and merge-group barriers', () => { + const text = workflow(); assert.ok(block(text, 'secret-scan').includes("if: ${{ github.event_name == 'pull_request' }}")); + assert.ok(!text.includes(' - secret-scan\n')); assert.match(text, /^ push:/m); assert.match(text, /^ pull_request:/m); assert.match(text, /^ merge_group:/m); +}); From 2d0dce13b142b77cdb75cf86748449b706b08c03 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:40:57 +0100 Subject: [PATCH 2/3] test(ci): align E2E contract with staged failure barriers Hosted self-tests exposed the older explicit no-frontend dependency rule. Retain the callee's independent runtime setup checks and assert the new reviewed prerequisites without weakening PR-only secret-scan isolation. Refs #2327 --- .../required-e2e-dependencies.test.mjs | 48 +++++-------------- 1 file changed, 13 insertions(+), 35 deletions(-) diff --git a/scripts/ci/smart-ci/required-e2e-dependencies.test.mjs b/scripts/ci/smart-ci/required-e2e-dependencies.test.mjs index 978edd3e2..10be1cda5 100644 --- a/scripts/ci/smart-ci/required-e2e-dependencies.test.mjs +++ b/scripts/ci/smart-ci/required-e2e-dependencies.test.mjs @@ -9,60 +9,38 @@ function extractTopLevelJob(workflow, jobName) { const lines = workflow.replaceAll('\r\n', '\n').split('\n') const jobsIndex = lines.findIndex((line) => line.trim() === 'jobs:') assert.notEqual(jobsIndex, -1, 'ci-required.yml is missing the top-level jobs mapping') - - const jobPattern = new RegExp(`^ ${jobName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}:\\s*$`) - const jobIndex = lines.findIndex((line, index) => index > jobsIndex && jobPattern.test(line)) + const jobIndex = lines.findIndex((line, index) => index > jobsIndex && line === ` ${jobName}:`) assert.notEqual(jobIndex, -1, `ci-required.yml is missing the top-level ${jobName} job`) - - const nextJobIndex = lines.findIndex( - (line, index) => index > jobIndex && /^ [A-Za-z0-9_-]+:\s*$/.test(line), - ) + const nextJobIndex = lines.findIndex((line, index) => index > jobIndex && /^ [A-Za-z0-9_-]+:\s*$/.test(line)) return lines.slice(jobIndex, nextJobIndex === -1 ? lines.length : nextJobIndex).join('\n') } - function extractNeeds(job) { - const lines = job.split('\n') - const needsIndex = lines.findIndex((line) => line.trim() === 'needs:') + const lines = job.split('\n'), needsIndex = lines.findIndex(line => line.trim() === 'needs:') assert.notEqual(needsIndex, -1, 'e2e-smoke is missing its needs list') - - const needsIndent = lines[needsIndex].search(/\S/) const dependencies = [] for (const line of lines.slice(needsIndex + 1)) { - const item = line.match(new RegExp(`^\\s{${needsIndent + 2}}-\\s*([A-Za-z0-9_-]+)\\s*$`)) - if (item) { - dependencies.push(item[1]) - continue - } - if (line.trim() === '') continue - break + const item = line.match(/^ -\s*([A-Za-z0-9_-]+)\s*$/) + if (item) dependencies.push(item[1]); else if (line.trim()) break } return dependencies } -test('required E2E waits on the independent required prerequisites', async () => { - const workflow = await readFile(requiredWorkflowUrl, 'utf8') - const e2eJob = extractTopLevelJob(workflow, 'e2e-smoke') - +test('required E2E waits on semantic and cheap control prerequisites', async () => { + const e2eJob = extractTopLevelJob(await readFile(requiredWorkflowUrl, 'utf8'), 'e2e-smoke') assert.deepEqual(extractNeeds(e2eJob).sort(), [ - 'api-integration', - 'backend-architecture', - 'backend-unit', - 'docs-governance', - 'migration-validation', + 'api-integration', 'backend-architecture', 'backend-unit', 'docs-governance', + 'frontend-unit', 'migration-validation', 'paper-color-audit', 'release-workflow-contract', ]) - assert.doesNotMatch(e2eJob, /^ - frontend-unit\s*$/m) + // This is a failure barrier, not artifact reuse. The callee still owns setup below. + // PR-only secret scan must not block push/merge-group through an unconditional needs edge. + assert.doesNotMatch(e2eJob, /^ - secret-scan\s*$/m) assert.match(e2eJob, /^ uses:\s*\.\/\.github\/workflows\/reusable-e2e-smoke\.yml\s*$/m) }) - test('reusable E2E owns its runtime setup and execution', async () => { const workflow = await readFile(reusableE2eWorkflowUrl, 'utf8') - assert.match(workflow, /- name: Checkout[\s\S]*?uses: actions\/checkout@/) assert.match(workflow, /- name: Setup \.NET[\s\S]*?uses: actions\/setup-dotnet@/) assert.match(workflow, /- name: Setup Node[\s\S]*?uses: actions\/setup-node@/) - assert.match( - workflow, - /- name: Install frontend dependencies[\s\S]*?working-directory: frontend\/taskdeck-web[\s\S]*?run: npm ci/, - ) + assert.match(workflow, /- name: Install frontend dependencies[\s\S]*?working-directory: frontend\/taskdeck-web[\s\S]*?run: npm ci/) assert.match(workflow, /- name: Run Playwright smoke tests[\s\S]*?run: npx playwright test/) }) From 49711f1f34cb45f8a6d855ef826235443b2fb3d5 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Thu, 10 Sep 2026 03:40:20 +0100 Subject: [PATCH 3/3] Normalize checkout line endings in workflow contracts --- scripts/ci/smart-ci/continuation/tests/workflow.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/smart-ci/continuation/tests/workflow.test.mjs b/scripts/ci/smart-ci/continuation/tests/workflow.test.mjs index 392ceaf18..4920ceb6d 100644 --- a/scripts/ci/smart-ci/continuation/tests/workflow.test.mjs +++ b/scripts/ci/smart-ci/continuation/tests/workflow.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { stageWorkflow } from '../tools/stage-taskdeck.mjs'; -const workflow = () => readFileSync(new URL('../../../../../.github/workflows/ci-required.yml', import.meta.url), 'utf8'); +const workflow = () => readFileSync(new URL('../../../../../.github/workflows/ci-required.yml', import.meta.url), 'utf8').replaceAll('\r\n', '\n'); function block(text, id) { const start = text.indexOf(`\n ${id}:\n`); assert.ok(start >= 0, `missing ${id}`); return text.slice(start + 1).split(/\n [a-z][a-z0-9-]*:\n/)[0];