diff --git a/.github/workflows/ci-required.yml b/.github/workflows/ci-required.yml index 50369d6cf..a58239fc1 100644 --- a/.github/workflows/ci-required.yml +++ b/.github/workflows/ci-required.yml @@ -11,7 +11,7 @@ # ├── reusable-backend-unit.yml domain / application / CLI unit tests (Ubuntu + Windows) # ├── reusable-api-integration.yml API integration tests (Ubuntu + Windows) # ├── reusable-migration-validation.yml EF Core migration chain validation (TST-61) -# ├── reusable-frontend-unit.yml dev launcher + lint + typecheck + build + unit tests (Ubuntu + Windows) +# ├── reusable-frontend-unit.yml lint + typecheck + build + unit tests (Ubuntu + Windows); dev launcher suite on Ubuntu only (CI-07 #2331) # ├── reusable-paper-color-audit.yml PAPER-12 hex-literal regression gate (#1008) # ├── reusable-container-images.yml compose validation + image build (tar export/upload only for nightly/release callers, CI-09) # ├── reusable-gitleaks.yml secret detection (PR scan, fail-on-detect) (#1132) diff --git a/.github/workflows/reusable-frontend-unit.yml b/.github/workflows/reusable-frontend-unit.yml index 086d6b908..828808c6e 100644 --- a/.github/workflows/reusable-frontend-unit.yml +++ b/.github/workflows/reusable-frontend-unit.yml @@ -39,7 +39,10 @@ jobs: # 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 diff --git a/ci/policy.v1.json b/ci/policy.v1.json index 969879aa3..8fc840fac 100644 --- a/ci/policy.v1.json +++ b/ci/policy.v1.json @@ -41,7 +41,7 @@ "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" }, "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": "dev launcher regression + full frontend duplicate today; launcher/platform subset after CI-08" }, + "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" }, "container-images": { "checkName": "Container Images / Container Images", "family": "containers", "runner": "selfHostedLinuxHeavy", "trustedOnly": true, "hostedFallback": "hostedLinux", "description": "compose validation + image build (+ artifact export today)" }, "secret-scan": { "checkName": "Secret Scan / Gitleaks Scan", "family": "security", "runner": "hostedLinux", "trustedOnly": false, "description": "diff-scoped secret scan - required branch-protection context" }, diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index f355fbb1a..a7be2e2a1 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -2,7 +2,7 @@ This is the active testing guide for Taskdeck. -Last Updated: 2026-09-02 +Last Updated: 2026-09-05 Companion Active Docs: - `docs/STATUS.md` - `docs/IMPLEMENTATION_MASTERPLAN.md` @@ -2338,6 +2338,17 @@ Required workflow: `.github/workflows/ci-required.yml` - Lint + coverage-threshold Vitest + typecheck + build - Ubuntu and Windows matrix - Uploads JUnit + coverage artifacts (`test-results/`, `coverage/`) for triage + + The source launcher regression suite (`scripts/ci/dev-up.test.mjs`) runs as a step of this job on + the **Linux leg only** (CI-07 `#2331`, SC-3: hosted minutes are Linux-only). The Bash launcher + cases therefore still run on every PR; the PowerShell launcher cases are local Windows evidence, + run from the repository root on Windows with + `node --test --test-concurrency=1 --test-timeout=30000 scripts/ci/dev-up.test.mjs`, until the + CI-04 `#2328` laptop runner is registered. On a Windows developer box that command is known to + red its `Bash:` cases under Git Bash (the local `#2378` cohort, recorded in `docs/STATUS.md`); the + `PowerShell:` cases are the evidence being asked for, so read the result per case rather than as + one pass/fail. The Windows leg keeps lint, typecheck, build and coverage unchanged. + `scripts/ci/smart-ci/launcher-suite-placement.test.mjs` pins that placement. - `container-images` - Runs `scripts/deploy/Test-TaskdeckReverseProxyConfig.ps1` against all four machine prefixes, static/rendered-template parity, forwarding/timeouts, hub WebSockets, MCP buffering, and SPA fallback diff --git a/docs/ci/SMART_CI.md b/docs/ci/SMART_CI.md index 75e57acc8..8e412501f 100644 --- a/docs/ci/SMART_CI.md +++ b/docs/ci/SMART_CI.md @@ -85,7 +85,7 @@ The lanes are the existing reusable workflows (ADR-0013), parameterized rather t | backend-unit | `reusable-backend-unit.yml` (Domain/Application/CLI) | Linux semantic; Windows only via the contract | | | api-integration | `reusable-api-integration.yml` → behavioural shards (CI-06) | Linux semantic; Windows contract shard | | | migration | `reusable-migration-validation.yml` | hosted Linux | selected by persistence groups | -| frontend | `reusable-frontend-unit.yml` | Linux once; Windows launcher/platform subset | CI-08 | +| frontend | `reusable-frontend-unit.yml` | Linux once (launcher suite Linux-only since CI-07 #2331); Windows platform subset without the launcher suite | CI-08 | | e2e-journey | `reusable-e2e-smoke.yml` by journey | self-hosted Linux / hosted | CI-08 | | containers | `reusable-container-images.yml`, `reusable-container-integration.yml` | self-hosted Linux / hosted | risk-gated (CI-08) | | windows-compat | worktree-helper harness, launchers, dev-up, MCP host, SQLite, desktop | Windows (self-hosted after CI-04) | CI-07 | diff --git a/scripts/ci/smart-ci/launcher-suite-placement.test.mjs b/scripts/ci/smart-ci/launcher-suite-placement.test.mjs new file mode 100644 index 000000000..5850b7115 --- /dev/null +++ b/scripts/ci/smart-ci/launcher-suite-placement.test.mjs @@ -0,0 +1,119 @@ +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 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', +] + +function workflowLines(workflow) { + return workflow.replaceAll('\r\n', '\n').split('\n') +} + +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') + + 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) + } + 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 +} + +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', + ) +}) + +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('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`, + ) + } +})