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..6b61234b8 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" }, @@ -94,21 +95,21 @@ { "id": "mcp-config", "patterns": [".mcp.json", "mcp*.example.json", "mcp-*.example.json"], "riskFloor": "R3", "lanes": ["api-integration-linux", "api-integration-windows"] }, { "id": "worktree-helpers", "patterns": ["scripts/git/**", "scripts/worktree_guard.ps1", "scripts/check-codex-path-trust.ps1", "scripts/github/**"], "riskFloor": "R2", "lanes": ["worktree-helper-windows", "docs-governance"] }, { "id": "governance-scripts", "patterns": ["scripts/check-*.mjs", "scripts/check-*.test.mjs"], "riskFloor": "R2", "lanes": ["docs-governance"] }, - { "id": "scripts-other", "patterns": ["scripts/**"], "riskFloor": "R2", "lanes": ["docs-governance"] }, - { "id": "backend-domain", "patterns": ["backend/src/Taskdeck.Domain/**"], "riskFloor": "R2", "lanes": ["backend-unit-linux", "backend-architecture", "api-integration-linux", "migration-validation"] }, - { "id": "backend-application", "patterns": ["backend/src/Taskdeck.Application/**"], "riskFloor": "R2", "lanes": ["backend-unit-linux", "backend-architecture", "api-integration-linux"] }, - { "id": "backend-infrastructure", "patterns": ["backend/src/Taskdeck.Infrastructure/**"], "riskFloor": "R2", "lanes": ["api-integration-linux", "backend-architecture", "migration-validation", "backend-unit-linux"] }, - { "id": "persistence-migrations", "patterns": ["backend/src/Taskdeck.Infrastructure/Migrations/**", "backend/src/Taskdeck.Infrastructure/Persistence/**"], "riskFloor": "R3", "lanes": ["migration-validation", "api-integration-linux", "api-integration-windows", "backend-architecture"] }, - { "id": "backend-api", "patterns": ["backend/src/Taskdeck.Api/**"], "riskFloor": "R2", "lanes": ["api-integration-linux", "backend-architecture", "e2e-smoke"] }, - { "id": "mcp-process", "patterns": ["backend/src/**/Mcp/**", "backend/src/**/*Mcp*", "backend/src/Taskdeck.Api/Program.cs", "backend/tests/Taskdeck.Api.Tests/**/*Mcp*", "scripts/mcp/**", ".mcp.json", "mcp*.example.json"], "riskFloor": "R3", "lanes": ["api-integration-linux", "api-integration-windows", "e2e-smoke"] }, - { "id": "auth-security", "patterns": ["backend/src/**/Auth/**", "backend/src/**/*Auth*", "backend/src/**/Security/**", "backend/src/**/*Security*", "backend/src/**/*Authorization*", "backend/src/**/*Identity*", "backend/src/**/*ApiKey*", "backend/src/**/*Mfa*", "backend/src/**/*Jwt*", "backend/src/**/*Password*", "backend/src/**/*Oidc*", "backend/src/**/*ExternalLogin*", "backend/src/**/*Registration*", "backend/src/**/*Token*", "backend/src/**/*Credential*", "backend/src/**/*Session*", "backend/src/**/Connectors/**", "backend/src/Taskdeck.Api/Middleware/**"], "riskFloor": "R3", "lanes": ["api-integration-linux", "backend-unit-linux", "e2e-smoke"] }, - { "id": "capture-proposal-executor", "patterns": ["backend/src/**/*Capture*", "backend/src/**/*Proposal*", "backend/src/**/*Provenance*", "backend/src/**/*Triage*", "backend/src/**/*Executor*", "backend/src/**/*Revision*", "backend/src/**/*Automation*", "backend/src/**/*Execution*", "backend/src/**/Pipeline/**", "backend/src/**/*Operation*"], "riskFloor": "R3", "lanes": ["backend-unit-linux", "api-integration-linux", "e2e-smoke"] }, - { "id": "backend-cli", "patterns": ["backend/src/Taskdeck.Cli/**"], "riskFloor": "R2", "lanes": ["backend-unit-linux", "backend-architecture"] }, - { "id": "backend-tests", "patterns": ["backend/tests/**"], "riskFloor": "R1", "lanes": ["backend-unit-linux", "api-integration-linux", "backend-architecture"] }, - { "id": "backend-project-files", "patterns": ["backend/**/*.csproj", "backend/Taskdeck.sln", "backend/stryker-config.json"], "riskFloor": "R3", "lanes": ["backend-unit-linux", "api-integration-linux", "backend-architecture", "migration-validation", "container-images", "dependency-security"] }, - { "id": "frontend-src", "patterns": ["frontend/taskdeck-web/src/**", "frontend/taskdeck-web/index.html", "frontend/taskdeck-web/public/**", "frontend/taskdeck-web/vite.config.ts", "frontend/taskdeck-web/vitest.config.ts", "frontend/taskdeck-web/tsconfig*.json", "frontend/taskdeck-web/tailwind.config.js", "frontend/taskdeck-web/postcss.config.js", "frontend/taskdeck-web/eslint.config.js", "frontend/taskdeck-web/stryker.config.mjs"], "riskFloor": "R2", "lanes": ["frontend-unit-linux", "paper-color-audit", "e2e-smoke"] }, - { "id": "frontend-e2e", "patterns": ["frontend/taskdeck-web/tests/**", "frontend/taskdeck-web/playwright*.ts"], "riskFloor": "R1", "lanes": ["e2e-smoke", "frontend-unit-linux"] }, - { "id": "launchers-windows", "patterns": ["frontend/taskdeck-web/scripts/**", "scripts/dev-up.ps1", "scripts/dev-up.sh", "scripts/backup.ps1", "scripts/restore.ps1", "scripts/clean-workspace.ps1", "backend/src/**/Desktop*", "backend/src/**/*Launcher*"], "riskFloor": "R3", "lanes": ["frontend-unit-windows", "frontend-unit-linux", "backend-unit-windows"] }, + { "id": "scripts-other", "patterns": ["scripts/**"], "riskFloor": "R2", "lanes": ["docs-governance", "source-launcher-linux"] }, + { "id": "backend-domain", "patterns": ["backend/src/Taskdeck.Domain/**"], "riskFloor": "R2", "lanes": ["backend-unit-linux", "backend-architecture", "api-integration-linux", "migration-validation", "source-launcher-linux"] }, + { "id": "backend-application", "patterns": ["backend/src/Taskdeck.Application/**"], "riskFloor": "R2", "lanes": ["backend-unit-linux", "backend-architecture", "api-integration-linux", "source-launcher-linux"] }, + { "id": "backend-infrastructure", "patterns": ["backend/src/Taskdeck.Infrastructure/**"], "riskFloor": "R2", "lanes": ["api-integration-linux", "backend-architecture", "migration-validation", "backend-unit-linux", "source-launcher-linux"] }, + { "id": "persistence-migrations", "patterns": ["backend/src/Taskdeck.Infrastructure/Migrations/**", "backend/src/Taskdeck.Infrastructure/Persistence/**"], "riskFloor": "R3", "lanes": ["migration-validation", "api-integration-linux", "api-integration-windows", "backend-architecture", "source-launcher-linux"] }, + { "id": "backend-api", "patterns": ["backend/src/Taskdeck.Api/**"], "riskFloor": "R2", "lanes": ["api-integration-linux", "backend-architecture", "e2e-smoke", "source-launcher-linux"] }, + { "id": "mcp-process", "patterns": ["backend/src/**/Mcp/**", "backend/src/**/*Mcp*", "backend/src/Taskdeck.Api/Program.cs", "backend/tests/Taskdeck.Api.Tests/**/*Mcp*", "scripts/mcp/**", ".mcp.json", "mcp*.example.json"], "riskFloor": "R3", "lanes": ["api-integration-linux", "api-integration-windows", "e2e-smoke", "source-launcher-linux"] }, + { "id": "auth-security", "patterns": ["backend/src/**/Auth/**", "backend/src/**/*Auth*", "backend/src/**/Security/**", "backend/src/**/*Security*", "backend/src/**/*Authorization*", "backend/src/**/*Identity*", "backend/src/**/*ApiKey*", "backend/src/**/*Mfa*", "backend/src/**/*Jwt*", "backend/src/**/*Password*", "backend/src/**/*Oidc*", "backend/src/**/*ExternalLogin*", "backend/src/**/*Registration*", "backend/src/**/*Token*", "backend/src/**/*Credential*", "backend/src/**/*Session*", "backend/src/**/Connectors/**", "backend/src/Taskdeck.Api/Middleware/**"], "riskFloor": "R3", "lanes": ["api-integration-linux", "backend-unit-linux", "e2e-smoke", "source-launcher-linux"] }, + { "id": "capture-proposal-executor", "patterns": ["backend/src/**/*Capture*", "backend/src/**/*Proposal*", "backend/src/**/*Provenance*", "backend/src/**/*Triage*", "backend/src/**/*Executor*", "backend/src/**/*Revision*", "backend/src/**/*Automation*", "backend/src/**/*Execution*", "backend/src/**/Pipeline/**", "backend/src/**/*Operation*"], "riskFloor": "R3", "lanes": ["backend-unit-linux", "api-integration-linux", "e2e-smoke", "source-launcher-linux"] }, + { "id": "backend-cli", "patterns": ["backend/src/Taskdeck.Cli/**"], "riskFloor": "R2", "lanes": ["backend-unit-linux", "backend-architecture", "source-launcher-linux"] }, + { "id": "backend-tests", "patterns": ["backend/tests/**"], "riskFloor": "R1", "lanes": ["backend-unit-linux", "api-integration-linux", "backend-architecture", "source-launcher-linux"] }, + { "id": "backend-project-files", "patterns": ["backend/**/*.csproj", "backend/Taskdeck.sln", "backend/stryker-config.json"], "riskFloor": "R3", "lanes": ["backend-unit-linux", "api-integration-linux", "backend-architecture", "migration-validation", "container-images", "dependency-security", "source-launcher-linux"] }, + { "id": "frontend-src", "patterns": ["frontend/taskdeck-web/src/**", "frontend/taskdeck-web/index.html", "frontend/taskdeck-web/public/**", "frontend/taskdeck-web/vite.config.ts", "frontend/taskdeck-web/vitest.config.ts", "frontend/taskdeck-web/tsconfig*.json", "frontend/taskdeck-web/tailwind.config.js", "frontend/taskdeck-web/postcss.config.js", "frontend/taskdeck-web/eslint.config.js", "frontend/taskdeck-web/stryker.config.mjs"], "riskFloor": "R2", "lanes": ["frontend-unit-linux", "paper-color-audit", "e2e-smoke", "source-launcher-linux"] }, + { "id": "frontend-e2e", "patterns": ["frontend/taskdeck-web/tests/**", "frontend/taskdeck-web/playwright*.ts"], "riskFloor": "R1", "lanes": ["e2e-smoke", "frontend-unit-linux", "source-launcher-linux"] }, + { "id": "launchers-windows", "patterns": ["frontend/taskdeck-web/scripts/**", "scripts/dev-up.ps1", "scripts/dev-up.sh", "scripts/backup.ps1", "scripts/restore.ps1", "scripts/clean-workspace.ps1", "backend/src/**/Desktop*", "backend/src/**/*Launcher*"], "riskFloor": "R3", "lanes": ["frontend-unit-windows", "frontend-unit-linux", "backend-unit-windows", "source-launcher-linux"] }, { "id": "containers-deploy", "patterns": ["deploy/**", "**/Dockerfile*", "**/docker-compose*.yml", ".dockerignore", "scripts/deploy/**"], "riskFloor": "R3", "lanes": ["container-images", "dependency-security"] }, { "id": "load-and-evals", "patterns": ["tests/**", "evals/**"], "riskFloor": "R1", "lanes": [] } ], diff --git a/docs/ci/continuation/LANES.md b/docs/ci/continuation/LANES.md new file mode 100644 index 000000000..998791b33 --- /dev/null +++ b/docs/ci/continuation/LANES.md @@ -0,0 +1,31 @@ +# Source-launcher and frontend verification boundaries + +Date: 2026-09-10. Owners: #2331, #2332 and #2329. Parent: [engineering contract](README.md). + +`reusable-frontend-unit.yml` defines two independent job families. `source-launcher` runs the existing regression once on hosted Linux; `frontend-unit` retains the complete Linux/Windows matrix and every previous frontend semantic check. No launcher source or test implementation is changed. + +The exact launcher command remains `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 for checkout/Node setup around the suite's own watchdog. Node uses the caller's version input. The new checkout disables credential persistence; preserve concurrent checkout hardening during integration. PowerShell cases remain governed by SC-3; this slice does not change #2858's cleanup implementation. + +## Independent results without lost qualification + +Previously a launcher failure stopped the Linux frontend job before lint/typecheck/build/coverage, and backend changes affected that mixed job's input identity. Sibling jobs can finish independently. A launcher failure still fails the reusable call/E2E prerequisite; it does not erase an independently successful frontend result. The caller's `needs: frontend-unit` waits for both families. + +Parallelism introduces one extra job's setup/rounding overhead. No measured savings are claimed. Full frontend coverage stays full; no partial threshold is substituted. + +## Canonical and historical accounting + +The existing shadow policy gains `source-launcher-linux`, context `Frontend Unit / Source Launcher (Linux)`, using hosted Linux. Its ownership is added to fifteen existing backend/frontend/launcher/script groups, without changing ANY existing group ID, path pattern or risk floor. Unknown paths therefore retain their original escalation and nightly suite mapping remains valid. No required check is registered. + +The adapter gives the new lane the backend/frontend/script closure. Post-split frontend fingerprints no longer depend on backend sources. Historical policies without the new lane retain the original broad compound-job contract; old evidence is not reinterpreted retroactively. All Taskdeck contracts remain unreviewed/reuse-disabled. + +Original policy blob: `8fc840fac37bef19790e3b4ec6e03280ea5c81ee`. Removing the new lane and its existing-group references reconstructs the original parsed policy exactly. Canonical receipt schema versions are unchanged. + +## Hosted integration correction + +The first hosted head `e66711b3` exposed nine self-test failures: a broad new ownership group lacked a nightly mapping and changed an overlap fixture; an inherited E2E test forbade the newly intended frontend barrier. The correction removes the broad group rather than weakening the nightly validator, inherits only existing ownership, and incorporates the parent E2E contract correction. Earlier failures remain recorded. Fresh hosted qualification is required. + +Local continuation plus launcher-placement tests originally passed 264/264 on Node 22.16.0/Linux, but that was not the complete existing Smart CI suite. The corrected local overlay, including in-progress provider tests, passes 309/309; the same local limitations remain. The actual product launcher/frontend suites, full hosted self-tests, independent review and maintainer review are still required. Do not claim earlier fixture success qualified the failed hosted head. + +## Rollback + +Restore the original launcher step when removing the separate job; never remove the new job alone. Remove its lane and existing-group references, and restore the broad adapter contract together. Preserve concurrent launcher fixes/credential hardening. Canonical coordinator-owned documents are left unchanged; reconcile their topology prose with this scoped record after the stack lands. 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..89475e692 100644 --- a/scripts/ci/smart-ci/launcher-suite-placement.test.mjs +++ b/scripts/ci/smart-ci/launcher-suite-placement.test.mjs @@ -2,118 +2,72 @@ 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') })) + return steps.map(s => ({ name: s.name, body: s.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 -} - -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 lane inherits existing ownership without masking unknown paths', 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') + assert.ok(!policy.pathGroups.some(g => g.id === 'source-launcher-inputs')) + for (const id of ['backend-domain', 'backend-api', 'frontend-src', 'frontend-e2e', 'scripts-other', 'launchers-windows']) { + assert.ok(policy.pathGroups.find(g => g.id === id).lanes.includes('source-launcher-linux')) } + assert.ok(!policy.pathGroups.some(g => g.patterns.includes('backend/**') || g.patterns.includes('frontend/**'))) })