From e66711b3bd9df2d944d6d44bd3aed0df84eaece9 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:32:51 +0100 Subject: [PATCH] perf(ci): isolate Linux launcher qualification from frontend semantics Keep the exact launcher command and full frontend OS matrix. Add an explicit canonical shadow lane and conservative backend/frontend/script ownership. Retain historical compound-job contracts in the adapter. Refs #2331, #2332, #2329 --- .github/workflows/reusable-frontend-unit.yml | 36 ++-- ci/policy.v1.json | 2 + docs/ci/continuation/LANES.md | 35 ++++ scripts/ci/smart-ci/continuation.test.mjs | 1 + .../continuation/adapters/taskdeck.mjs | 5 +- .../tests/launcher-inputs.test.mjs | 17 ++ .../launcher-suite-placement.test.mjs | 162 ++++++------------ 7 files changed, 139 insertions(+), 119 deletions(-) create mode 100644 docs/ci/continuation/LANES.md create mode 100644 scripts/ci/smart-ci/continuation/tests/launcher-inputs.test.mjs diff --git a/.github/workflows/reusable-frontend-unit.yml b/.github/workflows/reusable-frontend-unit.yml index 828808c6e..a198c2acc 100644 --- a/.github/workflows/reusable-frontend-unit.yml +++ b/.github/workflows/reusable-frontend-unit.yml @@ -13,6 +13,30 @@ permissions: contents: read jobs: + source-launcher: + name: Source Launcher (Linux) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + + # Same Linux-only launcher contract as the former frontend matrix step. + # A launcher failure must not erase unrelated frontend test results (#2332). + # Preserve #2378's step timeout and the suite's own nine-minute watchdog. + # PowerShell cases remain local Windows evidence under SC-3; no Windows suite is added or removed. + - name: Run source launcher regression suite + if: runner.os == 'Linux' + timeout-minutes: 10 + run: node --test --test-concurrency=1 --test-timeout=30000 scripts/ci/dev-up.test.mjs + frontend-unit: name: Frontend Unit (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -34,18 +58,6 @@ jobs: cache: npm cache-dependency-path: frontend/taskdeck-web/package-lock.json - # #2378: this step launches real PowerShell/Bash launchers and their API/Vite children. When - # it hangs it used to consume the whole job budget (cancellations observed at 25:00), taking - # the job's remaining steps down with it. A passed windows-latest run of this step measured - # 6m31s, so 10 minutes fails a hang fast while leaving ~1.5x headroom on a slow runner. The - # suite's own watchdog trips at 9 minutes first and logs which launcher trees leaked. - # CI-07 #2331 under the SC-3 re-ruling (hosted minutes are Linux-only): the PowerShell launcher - # cases are local Windows evidence now, while the Bash launcher cases still run on the Linux leg. - - name: Run source launcher regression suite - if: runner.os == 'Linux' - timeout-minutes: 10 - run: node --test --test-concurrency=1 --test-timeout=30000 scripts/ci/dev-up.test.mjs - - name: Install frontend dependencies working-directory: frontend/taskdeck-web run: npm ci diff --git a/ci/policy.v1.json b/ci/policy.v1.json index 8fc840fac..e8fc5aa08 100644 --- a/ci/policy.v1.json +++ b/ci/policy.v1.json @@ -40,6 +40,7 @@ "api-integration-linux": { "checkName": "API Integration / API Integration (ubuntu-latest)", "family": "backend", "runner": "selfHostedLinuxHeavy", "trustedOnly": true, "hostedFallback": "hostedLinux", "description": "API integration suite on Linux" }, "api-integration-windows": { "checkName": "API Integration / API Integration (windows-latest)", "family": "windows", "runner": "selfHostedWindowsCompat", "trustedOnly": true, "hostedFallback": "hostedWindows", "description": "API integration suite on Windows (full duplicate today; MCP/process/SQLite contract shard after CI-06/CI-07)" }, "migration-validation": { "checkName": "Migration Validation / Migration Validation", "family": "persistence", "runner": "hostedLinux", "trustedOnly": false, "description": "EF Core migration chain validation" }, + "source-launcher-linux": { "checkName": "Frontend Unit / Source Launcher (Linux)", "family": "launchers", "runner": "hostedLinux", "trustedOnly": false, "description": "source launcher regression, isolated from frontend semantics; Linux only under SC-3" }, "frontend-unit-linux": { "checkName": "Frontend Unit / Frontend Unit (ubuntu-latest)", "family": "frontend", "runner": "selfHostedLinuxHeavy", "trustedOnly": true, "hostedFallback": "hostedLinux", "description": "lint + typecheck + build + coverage + bundle budget" }, "frontend-unit-windows": { "checkName": "Frontend Unit / Frontend Unit (windows-latest)", "family": "windows", "runner": "selfHostedWindowsCompat", "trustedOnly": true, "hostedFallback": "hostedWindows", "description": "full frontend duplicate today; launcher/platform subset after CI-08 (launcher suite runs on the Linux leg only since CI-07 #2331)" }, "paper-color-audit": { "checkName": "Paper Color Audit / Paper Color Audit", "family": "frontend", "runner": "hostedLinux", "trustedOnly": false, "description": "hex-literal regression gate" }, @@ -88,6 +89,7 @@ ".semgrep/**" ], "pathGroups": [ + { "id": "source-launcher-inputs", "patterns": ["backend/**", "frontend/**", "scripts/**"], "riskFloor": "R1", "lanes": ["source-launcher-linux"] }, { "id": "docs", "patterns": ["docs/**", "**/*.md", "autodoc/**", "LICENSES/**", "LICENSE", ".github/ISSUE_TEMPLATE/**", ".github/pull_request_template.md", "design_handoff_taskdeck_paper/**"], "riskFloor": "R0", "lanes": ["docs-governance"] }, { "id": "repo-metadata", "patterns": [".editorconfig", ".gitattributes", ".gitignore", ".dockerignore", ".prettierrc*", ".prettierignore"], "riskFloor": "R0", "lanes": [] }, { "id": "agent-tooling", "patterns": [".claude/**", ".codex/**", "scripts/agent_hooks/**", "scripts/agentic/**", "AGENTS.md", "CLAUDE.md", "GEMINI.md"], "riskFloor": "R1", "lanes": ["docs-governance"] }, diff --git a/docs/ci/continuation/LANES.md b/docs/ci/continuation/LANES.md new file mode 100644 index 000000000..ba3b6f956 --- /dev/null +++ b/docs/ci/continuation/LANES.md @@ -0,0 +1,35 @@ +# Source-launcher and frontend verification boundaries + +Date: 2026-09-10. Owners: #2331, #2332 and #2329. Parent: [engineering contract](README.md). + +`reusable-frontend-unit.yml` now defines two independent job families. `source-launcher` runs the existing launcher regression once on hosted Linux; `frontend-unit` retains the complete Linux/Windows matrix and every previous frontend semantic check. No source launcher or test implementation is changed. + +The launcher command remains: + +```sh +node --test --test-concurrency=1 --test-timeout=30000 scripts/ci/dev-up.test.mjs +``` + +Its Linux guard and ten-minute step timeout are unchanged. The separate job has a fifteen-minute ceiling to allow checkout/Node setup around that step and retain the suite's own watchdog. Node uses the caller's existing pinned version input. The new checkout disables credential persistence; existing checkout hardening from concurrent PRs must be preserved when integrating. PowerShell cases remain governed by SC-3; this slice neither reintroduces them into hosted Windows nor changes #2858's cleanup implementation. + +## Why a distinct job + +Previously a launcher failure stopped the Linux frontend job before lint/typecheck/build/coverage, and backend changes affected that mixed job's input identity. The new sibling jobs can finish independently. A launcher failure remains visible and fails the reusable workflow; it does not erase an independently successful frontend result. The caller's `needs: frontend-unit` still waits for the reusable call, including both families, before E2E. + +Parallelism can improve healthy feedback latency but introduces one extra hosted job's setup/rounding overhead. No measured savings are claimed. The important first result is an honest task boundary for future evidence reuse. Full frontend coverage stays full; no partial coverage threshold is substituted. + +## Canonical policy and historical compatibility + +The existing shadow policy gains exactly one lane, `source-launcher-linux`, whose context is `Frontend Unit / Source Launcher (Linux)`. It uses hosted Linux without a new self-hosted entitlement. One additive path group covers `backend/**`, `frontend/**` and `scripts/**` conservatively. All old lanes/rules/security settings remain; policy mode is still `shadow`. No required check is registered. + +The continuation adapter recognises the new lane and gives it the backend/frontend/script launcher closure. With a post-split policy, frontend semantic fingerprints no longer depend on backend sources. When inspecting a historical policy without the new lane, the adapter retains the old broad mixed-job contract; it cannot reinterpret old frontend evidence as if the split had always existed. All Taskdeck contracts remain unreviewed and reuse-disabled. + +The original policy blob was `8fc840fac37bef19790e3b4ec6e03280ea5c81ee`. Removing the newly added lane and path group reconstructs the original parsed policy exactly. The canonical `ci-plan.v1`/`ci-run.v1` schema versions are not replaced. + +## Tests and adoption + +The updated root `launcher-suite-placement.test.mjs` checks Linux-only exact command/timeout, a single invocation, semantic independence, retained OS matrix, unconditional lint/type/build/coverage, read-only credentials and the new canonical lane. The continuation bridge also exercises current input closure and keeps its legacy-policy regression. + +Local combined continuation + placement run: **264 passed, zero failed/skipped/cancelled**, Node 22.16.0/Linux. This does not execute the product launcher suite, full frontend suite or the complete pre-existing Smart CI suite locally. Those require exact-head hosted qualification, plus independent and maintainer review under SC-10. A coordinator should reconcile the canonical Smart CI topology prose with this scoped implementation document after the stack lands; no leased canonical document is overwritten here. + +Rollback must restore the original launcher step and remove its separate job together, then remove the lane/path-group and restore adapter legacy behaviour. Never delete the new job alone and thereby lose the launcher suite. Do not revert concurrent launcher fixes or checkout hardening. Restoring any original compound-job proof still requires that compound input contract; isolated frontend proofs cannot qualify it. diff --git a/scripts/ci/smart-ci/continuation.test.mjs b/scripts/ci/smart-ci/continuation.test.mjs index a3ccb1c14..9978caf5c 100644 --- a/scripts/ci/smart-ci/continuation.test.mjs +++ b/scripts/ci/smart-ci/continuation.test.mjs @@ -7,3 +7,4 @@ import './continuation/tests/planner.test.mjs'; import './continuation/tests/adapter.test.mjs'; import './continuation/tests/repository.test.mjs'; import './continuation/tests/workflow.test.mjs'; +import './continuation/tests/launcher-inputs.test.mjs'; diff --git a/scripts/ci/smart-ci/continuation/adapters/taskdeck.mjs b/scripts/ci/smart-ci/continuation/adapters/taskdeck.mjs index e48f923cb..7078ecada 100644 --- a/scripts/ci/smart-ci/continuation/adapters/taskdeck.mjs +++ b/scripts/ci/smart-ci/continuation/adapters/taskdeck.mjs @@ -35,8 +35,9 @@ export function taskdeckContracts(policy) { let components = ['repository']; if (id.startsWith('backend-') || id.startsWith('api-integration') || id === 'migration-validation') components = ['backend', 'scripts']; if (id === 'frontend-unit-windows' || id === 'paper-color-audit') components = ['frontend', 'scripts']; - // Linux Frontend currently executes dev-up.test.mjs, including the API process. - if (id === 'frontend-unit-linux') components = ['launcher']; + // Preserve broad historical contracts when inspecting pre-split canonical plans. + if (id === 'frontend-unit-linux') components = Object.hasOwn(policy.lanes, 'source-launcher-linux') ? ['frontend', 'scripts'] : ['launcher']; + if (id === 'source-launcher-linux') components = ['launcher']; if (id === 'e2e-smoke' || id === 'container-images') components = ['journeys']; const isWindows = id.endsWith('-windows') || id === 'worktree-helper-windows'; graph.tasks[id] = { diff --git a/scripts/ci/smart-ci/continuation/tests/launcher-inputs.test.mjs b/scripts/ci/smart-ci/continuation/tests/launcher-inputs.test.mjs new file mode 100644 index 000000000..9cb0fce60 --- /dev/null +++ b/scripts/ci/smart-ci/continuation/tests/launcher-inputs.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { taskdeckContracts } from '../adapters/taskdeck.mjs'; +import { inputPatterns } from '../core/contracts.mjs'; +import { matches } from '../core/primitives.mjs'; + +test('current canonical policy separates launcher inputs while preserving the frontend matrix', () => { + const policy = JSON.parse(readFileSync(new URL('../../../../../ci/policy.v1.json', import.meta.url))); + const graph = taskdeckContracts(policy); + assert.equal(matches('backend/src/Taskdeck.Api/Program.cs', inputPatterns(graph, 'frontend-unit-linux')), false); + assert.equal(matches('backend/src/Taskdeck.Api/Program.cs', inputPatterns(graph, 'source-launcher-linux')), true); + assert.equal(matches('frontend/taskdeck-web/src/main.ts', inputPatterns(graph, 'source-launcher-linux')), true); + assert.equal(matches('scripts/dev-up.sh', inputPatterns(graph, 'source-launcher-linux')), true); + assert.ok(graph.tasks['frontend-unit-windows']); + assert.ok(Object.values(graph.tasks).every(task => task.reviewed === false)); +}); diff --git a/scripts/ci/smart-ci/launcher-suite-placement.test.mjs b/scripts/ci/smart-ci/launcher-suite-placement.test.mjs index 5850b7115..c318a1b23 100644 --- a/scripts/ci/smart-ci/launcher-suite-placement.test.mjs +++ b/scripts/ci/smart-ci/launcher-suite-placement.test.mjs @@ -2,118 +2,70 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { test } from 'node:test' -const frontendUnitWorkflowUrl = new URL( - '../../../.github/workflows/reusable-frontend-unit.yml', - import.meta.url, -) - +const frontendUnitWorkflowUrl = new URL('../../../.github/workflows/reusable-frontend-unit.yml', import.meta.url) +const policyUrl = new URL('../../../ci/policy.v1.json', import.meta.url) const LAUNCHER_STEP_NAME = 'Run source launcher regression suite' const UNCONDITIONAL_STEP_NAMES = [ - 'Run frontend lint', - 'Run frontend typecheck', - 'Run frontend build', - 'Run frontend tests with coverage thresholds', + 'Run frontend lint', 'Run frontend typecheck', 'Run frontend build', 'Run frontend tests with coverage thresholds', ] - -function workflowLines(workflow) { - return workflow.replaceAll('\r\n', '\n').split('\n') +function extractJob(workflow, id) { + const lines = workflow.replaceAll('\r\n', '\n').split('\n') + const jobsIndex = lines.findIndex(line => line.trim() === 'jobs:') + assert.notEqual(jobsIndex, -1) + const jobIndex = lines.findIndex((line, i) => i > jobsIndex && line === ` ${id}:`) + assert.notEqual(jobIndex, -1, `missing ${id}`) + const next = lines.findIndex((line, i) => i > jobIndex && /^ {2}[A-Za-z0-9_-]+:\s*$/.test(line)) + return lines.slice(jobIndex, next === -1 ? lines.length : next) } - -function extractFrontendUnitJob(workflow) { - const lines = workflowLines(workflow) - const jobsIndex = lines.findIndex((line) => line.trim() === 'jobs:') - assert.notEqual(jobsIndex, -1, 'reusable-frontend-unit.yml is missing the top-level jobs mapping') - - const jobIndex = lines.findIndex( - (line, index) => index > jobsIndex && /^ {2}frontend-unit:\s*$/.test(line), - ) - assert.notEqual(jobIndex, -1, 'reusable-frontend-unit.yml is missing the frontend-unit job') - - const nextJobIndex = lines.findIndex( - (line, index) => index > jobIndex && /^ {2}[A-Za-z0-9_-]+:\s*$/.test(line), - ) - return lines.slice(jobIndex, nextJobIndex === -1 ? lines.length : nextJobIndex) -} - -function extractMatrixOperatingSystems(jobLines) { - const osIndex = jobLines.findIndex((line) => /^ {8}os:\s*$/.test(line)) - assert.notEqual(osIndex, -1, 'frontend-unit is missing its matrix.os list') - - const operatingSystems = [] - for (const line of jobLines.slice(osIndex + 1)) { - const item = line.match(/^ {10}-\s*([A-Za-z0-9_.-]+)\s*$/) - if (item) { - operatingSystems.push(item[1]) - continue - } - if (line.trim() === '') continue - break - } - return operatingSystems -} - -function extractSteps(jobLines) { - const stepsIndex = jobLines.findIndex((line) => /^ {4}steps:\s*$/.test(line)) - assert.notEqual(stepsIndex, -1, 'frontend-unit is missing its steps list') - +function extractSteps(lines) { + const start = lines.findIndex(line => /^ {4}steps:\s*$/.test(line)) + assert.notEqual(start, -1) const steps = [] - let current = null - for (const line of jobLines.slice(stepsIndex + 1)) { - if (/^ {6}- /.test(line)) { - const name = line.match(/^ {6}-\s*name:\s*(.+?)\s*$/) - current = { name: name ? name[1] : null, body: [line] } - steps.push(current) - continue - } - if (current) current.body.push(line) + for (const line of lines.slice(start + 1)) { + if (/^ {6}- /.test(line)) steps.push({ name: line.match(/^ {6}-\s*name:\s*(.+?)\s*$/)?.[1], body: [] }) + if (steps.length) steps.at(-1).body.push(line) } - return steps.map((step) => ({ name: step.name, body: step.body.join('\n') })) -} - -function findStep(steps, name) { - const step = steps.find((candidate) => candidate.name === name) - assert.ok(step, `frontend-unit is missing the "${name}" step`) - return step -} - -function extractStepCondition(step) { - const condition = step.body.match(/^ {8}if:\s*(.+?)\s*$/m) - return condition ? condition[1] : null + return steps.map(s => ({ name: s.name, body: s.body.join('\n') })) } - -test('the launcher regression suite runs on the Linux leg only (#2331, SC-3)', async () => { - const workflow = await readFile(frontendUnitWorkflowUrl, 'utf8') - const steps = extractSteps(extractFrontendUnitJob(workflow)) - const launcherStep = findStep(steps, LAUNCHER_STEP_NAME) - - assert.match( - launcherStep.body, - /^ {8}run: node --test --test-concurrency=1 --test-timeout=30000 scripts\/ci\/dev-up\.test\.mjs$/m, - 'the launcher step must still run scripts/ci/dev-up.test.mjs', - ) - assert.equal( - extractStepCondition(launcherStep), - "runner.os == 'Linux'", - 'the launcher step must be gated to the Linux leg', - ) +function step(steps, name) { const found = steps.find(s => s.name === name); assert.ok(found, `missing ${name}`); return found } +const condition = s => s.body.match(/^ {8}if:\s*(.+?)\s*$/m)?.[1] ?? null + +test('source launcher remains Linux-only with the exact command and step budget (#2331/#2332)', async () => { + const text = await readFile(frontendUnitWorkflowUrl, 'utf8'), lines = extractJob(text, 'source-launcher') + assert.ok(lines.includes(' runs-on: ubuntu-latest')) + assert.ok(!lines.some(line => /^ {4}if:/.test(line))) + const launcher = step(extractSteps(lines), LAUNCHER_STEP_NAME) + assert.match(launcher.body, /^ {8}run: node --test --test-concurrency=1 --test-timeout=30000 scripts\/ci\/dev-up\.test\.mjs$/m) + assert.match(launcher.body, /^ {8}timeout-minutes: 10$/m) + assert.equal(condition(launcher), "runner.os == 'Linux'") + assert.equal(text.split('scripts/ci/dev-up.test.mjs').length - 1, 1) }) - -test('the frontend-unit matrix still covers both hosted operating systems', async () => { - const workflow = await readFile(frontendUnitWorkflowUrl, 'utf8') - const operatingSystems = extractMatrixOperatingSystems(extractFrontendUnitJob(workflow)) - - assert.deepEqual([...operatingSystems].sort(), ['ubuntu-latest', 'windows-latest']) +test('frontend semantics no longer execute launcher tests or wait for launcher results', async () => { + const lines = extractJob(await readFile(frontendUnitWorkflowUrl, 'utf8'), 'frontend-unit') + assert.ok(!lines.some(line => /dev-up\.test|^ {4}needs:/.test(line))) }) - -test('lint, typecheck, build and coverage stay unconditional on both legs', async () => { - const workflow = await readFile(frontendUnitWorkflowUrl, 'utf8') - const steps = extractSteps(extractFrontendUnitJob(workflow)) - - for (const name of UNCONDITIONAL_STEP_NAMES) { - assert.equal( - extractStepCondition(findStep(steps, name)), - null, - `"${name}" must carry no OS condition`, - ) - } +test('frontend-unit retains both hosted operating systems', async () => { + const lines = extractJob(await readFile(frontendUnitWorkflowUrl, 'utf8'), 'frontend-unit') + const index = lines.findIndex(line => /^ {8}os:\s*$/.test(line)); assert.notEqual(index, -1) + const os = [] + for (const line of lines.slice(index + 1)) { const match = line.match(/^ {10}-\s*([A-Za-z0-9_.-]+)\s*$/); if (match) os.push(match[1]); else if (line.trim()) break } + assert.deepEqual(os.sort(), ['ubuntu-latest', 'windows-latest']) +}) +test('lint/typecheck/build/full coverage remain unconditional on both frontend legs', async () => { + const steps = extractSteps(extractJob(await readFile(frontendUnitWorkflowUrl, 'utf8'), 'frontend-unit')) + for (const name of UNCONDITIONAL_STEP_NAMES) assert.equal(condition(step(steps, name)), null) +}) +test('new launcher checkout has no persisted credentials and no write grants', async () => { + const text = await readFile(frontendUnitWorkflowUrl, 'utf8'), steps = extractSteps(extractJob(text, 'source-launcher')) + assert.match(step(steps, 'Checkout').body, /persist-credentials: false/) + assert.ok(!/\bwrite\b/.test(text)); assert.ok(!extractJob(text, 'source-launcher').some(line => /continue-on-error:/.test(line))) +}) +test('canonical shadow policy accounts for the new job and its coarse input boundary', async () => { + const policy = JSON.parse(await readFile(policyUrl, 'utf8')) + assert.equal(policy.mode, 'shadow') + assert.equal(policy.lanes['source-launcher-linux'].checkName, 'Frontend Unit / Source Launcher (Linux)') + assert.equal(policy.lanes['source-launcher-linux'].runner, 'hostedLinux') + const group = policy.pathGroups.find(g => g.id === 'source-launcher-inputs') + assert.deepEqual(group.patterns, ['backend/**', 'frontend/**', 'scripts/**']) + assert.deepEqual(group.lanes, ['source-launcher-linux']) })