From e9b8e2236c90983c1fb3c0aae2ff9ae589303163 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:13:39 +0100 Subject: [PATCH 01/10] fix(release): restrict GHCR publishing to version tag pushes Require a real push event for v-prefixed tag refs in the Docker publish job while preserving the exact audited-SHA gate. Keep workflow_dispatch available only for the no-write release-gate proof, with explicit simulated tag-push inputs and negative coverage for manual dispatch. --- .github/workflows/docker.yml | 4 +- .github/workflows/release-gate-proof.yml | 29 ++++++++--- README.md | 8 ++-- scripts/release-security-gate.mjs | 39 ++++++++++++--- .../unit/tooling/releaseSecurityGate.test.ts | 48 ++++++++++++++++--- 5 files changed, 103 insertions(+), 25 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index fc35b93ba..a56e2e5f7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -71,10 +71,10 @@ jobs: build-push: name: Build & push image - # QNBS-v3 (#911): every pushed tag (version, major.minor, and `latest` from metadata-action's latest=auto or the raw rule) waits on a successful audit of this exact SHA; no status function, so a failed, skipped or cancelled audit leaves this job unrun. + # QNBS-v3 (#911): only signed v* tag pushes may publish; manual dispatch remains audit-only. needs: [security-audit] if: >- - ${{ needs.security-audit.result == 'success' && needs.security-audit.outputs.audited-sha == github.sha }} + ${{ github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && needs.security-audit.result == 'success' && needs.security-audit.outputs.audited-sha == github.sha }} runs-on: ubuntu-latest timeout-minutes: 20 # QNBS-v3: packages:write scoped to this job only — prevents privilege escalation in future jobs. diff --git a/.github/workflows/release-gate-proof.yml b/.github/workflows/release-gate-proof.yml index 9e011dd25..8a3f09a9b 100644 --- a/.github/workflows/release-gate-proof.yml +++ b/.github/workflows/release-gate-proof.yml @@ -1,8 +1,8 @@ -# Non-publishing proof of the #911 release security gate (same inlined audit job as tauri-build.yml / docker.yml). Dispatch it on main with: -# expected-sha = the dispatched commit -> audit succeeds, the publish stand-in runs (echo only) -# expected-sha = another full SHA -> audit may succeed, the stand-in is skipped (SHA mismatch) -# expected-sha = not-a-sha -> audit fails, the stand-in is skipped -# The stand-in's `if` is identical to docker.yml build-push (tests/unit/tooling/releaseSecurityGate.test.ts). +# Non-publishing proof of the #911 release security gate. Dispatch on main with: +# expected-sha = dispatch SHA; simulated_event=push; simulated_ref_type=tag; +# simulated_ref=refs/tags/v1.29.3 -> audit succeeds, echo-only stand-in runs +# A wrong SHA, non-tag ref, or simulated non-push event keeps the stand-in skipped. +# The simulated event is data only; this workflow has no publishing permission or action. # Nothing here holds a write permission, logs in to a registry or creates a release. name: Release gate proof @@ -13,6 +13,23 @@ on: description: SHA to hand to the release security audit (use a wrong or invalid value for the negative proofs). required: true type: string + simulated_event: + description: Event modeled by the no-write stand-in; choose push only for the positive tag-path proof. + required: true + type: choice + options: [push, workflow_dispatch] + default: workflow_dispatch + simulated_ref_type: + description: Ref type modeled by the no-write stand-in. + required: true + type: choice + options: [branch, tag] + default: branch + simulated_ref: + description: Ref modeled by the no-write stand-in, for example refs/tags/v1.29.3. + required: true + type: string + default: refs/heads/main permissions: contents: read @@ -77,7 +94,7 @@ jobs: name: Publish stand-in (no side effects) needs: [security-audit] if: >- - ${{ needs.security-audit.result == 'success' && needs.security-audit.outputs.audited-sha == github.sha }} + ${{ github.event_name == 'workflow_dispatch' && inputs.simulated_event == 'push' && inputs.simulated_ref_type == 'tag' && startsWith(inputs.simulated_ref, 'refs/tags/v') && needs.security-audit.result == 'success' && needs.security-audit.outputs.audited-sha == github.sha }} runs-on: ubuntu-latest timeout-minutes: 2 permissions: diff --git a/README.md b/README.md index cb764736e..9bb1f9757 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ TypeScript native preview (tsgo) Tauri 2 19 locales — 2963 keys - 8698+ tests / 655 files + 8701+ tests / 655 files Codecov Coverage CI status MIT License @@ -807,7 +807,7 @@ The normal web PR pipeline is not the complete native release qualification surf | DOCX | `docx` + JSZip | Word-compatible export | | PWA | Service Worker + Web App Manifest | Offline shell/installability | | i18n | Custom React i18n context | 2963 keys × 19 locales | -| Testing | Vitest 5.x (8698+ tests / 655 files) + Playwright | Unit/integration/E2E | +| Testing | Vitest 5.x (8701+ tests / 655 files) + Playwright | Unit/integration/E2E | | Quality | Biome + tsgo + CodeQL/security tooling | Static and CI gates | | Desktop | Tauri 2 | Current native shell | @@ -884,7 +884,7 @@ WorldScript-Studio/ ├── locales/ # Source locale trees ├── public/ # PWA assets, manifest, SW, runtime locale bundles ├── tests/ -│ ├── unit/ # Vitest unit tests (the 8698+ total also includes components/ and packages/*/tests; tests/e2e is excluded) +│ ├── unit/ # Vitest unit tests (the 8701+ total also includes components/ and packages/*/tests; tests/e2e is excluded) │ └── e2e/ # Playwright ├── docs/ # Canonical product/engineering documentation + ADRs ├── src-tauri/ # Tauri v2 desktop shell / Rust @@ -1029,7 +1029,7 @@ Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendo Current source-synchronized README metrics: -- **8698+ unit tests** across **655 test files** +- **8701+ unit tests** across **655 test files** - i18n: **2963 keys × 19 locales** CI remains authoritative for actual pass/fail and live coverage. diff --git a/scripts/release-security-gate.mjs b/scripts/release-security-gate.mjs index 3d8604aff..fdf90bb20 100644 --- a/scripts/release-security-gate.mjs +++ b/scripts/release-security-gate.mjs @@ -18,11 +18,32 @@ export const GITHUB_SHA = expression('github.sha'); export const PROOF_INPUT_SHA = expression('inputs.expected-sha'); export const AUDITED_SHA_OUTPUT = expression('steps.result.outputs.audited-sha'); +export const REQUIRED_DOCKER_PUBLISH_EVENTS = Object.freeze([ + "github.event_name == 'push'", + "github.ref_type == 'tag'", + "startsWith(github.ref, 'refs/tags/v')", +]); + +export const REQUIRED_PROOF_SIMULATION_CONDITIONS = Object.freeze([ + "github.event_name == 'workflow_dispatch'", + "inputs.simulated_event == 'push'", + "inputs.simulated_ref_type == 'tag'", + "startsWith(inputs.simulated_ref, 'refs/tags/v')", +]); + /** Workflow → the jobs that publish (or stand in for publishing) and the SHA their audit must check. */ export const RELEASE_GATED_WORKFLOWS = Object.freeze({ 'tauri-build.yml': { gatedJobs: ['release'], expectedSha: GITHUB_SHA }, - 'docker.yml': { gatedJobs: ['build-push'], expectedSha: GITHUB_SHA }, - 'release-gate-proof.yml': { gatedJobs: ['publish-stand-in'], expectedSha: PROOF_INPUT_SHA }, + 'docker.yml': { + gatedJobs: ['build-push'], + expectedSha: GITHUB_SHA, + requiredEventConditions: REQUIRED_DOCKER_PUBLISH_EVENTS, + }, + 'release-gate-proof.yml': { + gatedJobs: ['publish-stand-in'], + expectedSha: PROOF_INPUT_SHA, + requiredEventConditions: REQUIRED_PROOF_SIMULATION_CONDITIONS, + }, }); export const REQUIRED_GATE_CONDITIONS = Object.freeze([ @@ -154,8 +175,10 @@ function checkAuditJob(audit, expectedSha, fail) { checkAuditSteps(asList(audit.steps), expectedSha, fail); } -function checkGateCondition(jobName, condition, fail) { - const missing = REQUIRED_GATE_CONDITIONS.filter((required) => !condition.includes(required)); +function checkGateCondition(jobName, condition, fail, requiredEventConditions = []) { + const missing = [...REQUIRED_GATE_CONDITIONS, ...requiredEventConditions].filter( + (required) => !condition.includes(required), + ); for (const required of missing) fail(`job "${jobName}" if: must require ${required}`); if (STATUS_FUNCTION.test(condition)) { fail( @@ -164,7 +187,7 @@ function checkGateCondition(jobName, condition, fail) { } } -function checkGatedJob(jobName, job, fail) { +function checkGatedJob(jobName, job, fail, requiredEventConditions) { if (!job) { fail(`gated job "${jobName}" is missing`); return; @@ -172,7 +195,7 @@ function checkGatedJob(jobName, job, fail) { if (!asList(job.needs).includes('security-audit')) { fail(`job "${jobName}" must need "security-audit"`); } - checkGateCondition(jobName, String(job.if ?? ''), fail); + checkGateCondition(jobName, String(job.if ?? ''), fail, requiredEventConditions); } function checkNoUngatedWriters(jobs, gatedJobs, fail) { @@ -207,7 +230,9 @@ export function checkReleaseSecurityWorkflow(fileName, source) { } const jobs = workflow?.jobs ?? {}; checkAuditJob(jobs['security-audit'], spec.expectedSha, fail); - for (const jobName of spec.gatedJobs) checkGatedJob(jobName, jobs[jobName], fail); + for (const jobName of spec.gatedJobs) { + checkGatedJob(jobName, jobs[jobName], fail, spec.requiredEventConditions); + } checkNoUngatedWriters(jobs, spec.gatedJobs, fail); return failures; } diff --git a/tests/unit/tooling/releaseSecurityGate.test.ts b/tests/unit/tooling/releaseSecurityGate.test.ts index 959b132a3..7e8fa9e26 100644 --- a/tests/unit/tooling/releaseSecurityGate.test.ts +++ b/tests/unit/tooling/releaseSecurityGate.test.ts @@ -18,6 +18,8 @@ const expr = (expression: string) => `\${{ ${expression} }}`; const gateCondition = "needs.security-audit.result == 'success' && needs.security-audit.outputs.audited-sha == github.sha"; const tauriReleaseCondition = `github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && ${gateCondition}`; +const dockerTagPushCondition = `github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && ${gateCondition}`; +const proofTagSimulationCondition = `github.event_name == 'workflow_dispatch' && inputs.simulated_event == 'push' && inputs.simulated_ref_type == 'tag' && startsWith(inputs.simulated_ref, 'refs/tags/v') && ${gateCondition}`; function mutate(text: string, from: string, to: string): string { expect(text, `fixture anchor not found: ${from}`).toContain(from); @@ -38,13 +40,33 @@ describe('#911 release security gate: shipped workflows', () => { expect(checkWorkflowFile(join(workflows, name))).toEqual([]); }); - it('Tauri release and GHCR build-push use the exact gate conditions', () => { + it('Tauri and GHCR publish only from exact gated v* tag pushes', () => { expect(source(TAURI)).toContain(expr(tauriReleaseCondition)); expect(source(TAURI)).toContain('needs: [bundle, security-audit]'); - expect(source(DOCKER)).toContain(expr(gateCondition)); + expect(source(DOCKER)).toContain(expr(dockerTagPushCondition)); expect(source(DOCKER)).toContain('needs: [security-audit]'); }); + it('rejects a Docker publish job that could run from workflow_dispatch', () => { + const dispatchCanPublish = mutate( + source(DOCKER), + "github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && ", + '', + ); + expect(messages(DOCKER, dispatchCanPublish)).toMatch( + /must require github\.event_name == 'push'/, + ); + }); + + it('rejects a workflow_dispatch even when its ref happens to be a v* tag', () => { + const dispatchOnTag = mutate( + source(DOCKER), + "github.event_name == 'push'", + "github.event_name == 'workflow_dispatch'", + ); + expect(messages(DOCKER, dispatchOnTag)).toMatch(/must require github\.event_name == 'push'/); + }); + it('GHCR builds exactly the audited commit', () => { expect(source(DOCKER)).toMatch(/ref: \$\{\{ github\.sha \}\}\n\s+persist-credentials: false/); }); @@ -92,8 +114,10 @@ describe('#911 release security gate: weakening it fails', () => { [ 'GHCR build-push when the audit is not cancelled', DOCKER, - expr(gateCondition), - expr(`!cancelled() && ${gateCondition}`), + expr(dockerTagPushCondition), + expr( + `github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !cancelled() && ${gateCondition}`, + ), /must not use a status function/, ], [ @@ -233,8 +257,20 @@ describe('#911 non-publishing gate proof workflow', () => { expect(source(PROOF)).not.toMatch(/docker\/login-action|action-gh-release|gh release/); }); - it('its stand-in uses the exact GHCR build-push condition and audits the dispatch input', () => { - expect(source(PROOF)).toContain(expr(gateCondition)); + it('its stand-in simulates only a v* tag push and audits the dispatch input', () => { + expect(source(PROOF)).toContain(expr(proofTagSimulationCondition)); expect(source(PROOF)).toContain(`EXPECTED_SHA: ${expr('inputs.expected-sha')}`); + expect(messages(PROOF, source(PROOF))).toEqual(''); + }); + + it('rejects a proof stand-in that treats a simulated manual dispatch as a publish', () => { + const dispatchAsPublish = mutate( + source(PROOF), + "inputs.simulated_event == 'push'", + "inputs.simulated_event == 'workflow_dispatch'", + ); + expect(messages(PROOF, dispatchAsPublish)).toMatch( + /must require inputs\.simulated_event == 'push'/, + ); }); }); From e89e8d38491c9a4a370ee01bd4def7d5ce9b67a6 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:20:01 +0100 Subject: [PATCH 02/10] docs(changelog): reference GHCR tag-publish guard Record the release-pipeline boundary under the actual successor PR number so the changelog guard and release history remain traceable. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b1203d7..65073fce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- **Container publishing:** GHCR releases now require an actual `v*` tag push and a successful security audit of the exact commit; manual workflow dispatch remains non-publishing (#1205). + ## [1.29.3] — 2026-11-08 From c548ef2456c171cb3ee7ccb24f81b18960df9c40 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:40:41 +0100 Subject: [PATCH 03/10] fix(release): verify signed tags before GHCR publish Require Docker publication to depend on the repository's annotated-tag and target-commit signature verifier, in addition to the exact-SHA OSV audit and real version-tag push. Make the release-security checker require an exact conjunctive condition and reject alternate OR paths; model signature success explicitly in the no-write proof and document the distinction. --- .github/workflows/docker.yml | 29 ++- .github/workflows/release-gate-proof.yml | 14 +- README.md | 8 +- docs/CI.md | 204 +++++++++--------- scripts/release-security-gate.d.mts | 15 +- scripts/release-security-gate.mjs | 119 ++++++++-- .../unit/tooling/releaseSecurityGate.test.ts | 108 +++++++++- 7 files changed, 364 insertions(+), 133 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a56e2e5f7..11bfd7417 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -69,12 +69,35 @@ jobs: id: result run: echo "audited-sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + verify-release-tag: + name: Verify release tag + if: >- + ${{ github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out the tagged source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Verify GitHub signatures for the release tag + env: + GITHUB_EVENT_NAME: ${{ github.event_name }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_REF_TYPE: ${{ github.ref_type }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/signing/verify-github-signatures.mjs + build-push: name: Build & push image - # QNBS-v3 (#911): only signed v* tag pushes may publish; manual dispatch remains audit-only. - needs: [security-audit] + # QNBS-v3 (#911): publish only a verified signed v* tag push after the exact-SHA audit. + needs: [security-audit, verify-release-tag] if: >- - ${{ github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && needs.security-audit.result == 'success' && needs.security-audit.outputs.audited-sha == github.sha }} + ${{ github.event_name == 'push' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && needs.verify-release-tag.result == 'success' && needs.security-audit.result == 'success' && needs.security-audit.outputs.audited-sha == github.sha }} runs-on: ubuntu-latest timeout-minutes: 20 # QNBS-v3: packages:write scoped to this job only — prevents privilege escalation in future jobs. diff --git a/.github/workflows/release-gate-proof.yml b/.github/workflows/release-gate-proof.yml index 8a3f09a9b..fe4e42640 100644 --- a/.github/workflows/release-gate-proof.yml +++ b/.github/workflows/release-gate-proof.yml @@ -1,8 +1,8 @@ # Non-publishing proof of the #911 release security gate. Dispatch on main with: # expected-sha = dispatch SHA; simulated_event=push; simulated_ref_type=tag; -# simulated_ref=refs/tags/v1.29.3 -> audit succeeds, echo-only stand-in runs -# A wrong SHA, non-tag ref, or simulated non-push event keeps the stand-in skipped. -# The simulated event is data only; this workflow has no publishing permission or action. +# simulated_ref=refs/tags/v1.29.3; simulated_tag_signature=verified -> stand-in runs +# A wrong SHA, non-tag ref, unverified signature result, or non-push simulation keeps it skipped. +# Event/ref/signature inputs are data only; this workflow has no publishing permission or action. # Nothing here holds a write permission, logs in to a registry or creates a release. name: Release gate proof @@ -30,6 +30,12 @@ on: required: true type: string default: refs/heads/main + simulated_tag_signature: + description: Simulated signature-verifier result; this input does not verify a real tag. + required: true + type: choice + options: [verified, rejected] + default: rejected permissions: contents: read @@ -94,7 +100,7 @@ jobs: name: Publish stand-in (no side effects) needs: [security-audit] if: >- - ${{ github.event_name == 'workflow_dispatch' && inputs.simulated_event == 'push' && inputs.simulated_ref_type == 'tag' && startsWith(inputs.simulated_ref, 'refs/tags/v') && needs.security-audit.result == 'success' && needs.security-audit.outputs.audited-sha == github.sha }} + ${{ github.event_name == 'workflow_dispatch' && inputs.simulated_event == 'push' && inputs.simulated_ref_type == 'tag' && startsWith(inputs.simulated_ref, 'refs/tags/v') && inputs.simulated_tag_signature == 'verified' && needs.security-audit.result == 'success' && needs.security-audit.outputs.audited-sha == github.sha }} runs-on: ubuntu-latest timeout-minutes: 2 permissions: diff --git a/README.md b/README.md index 9bb1f9757..9474515fb 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ TypeScript native preview (tsgo) Tauri 2 19 locales — 2963 keys - 8701+ tests / 655 files + 8709+ tests / 655 files Codecov Coverage CI status MIT License @@ -807,7 +807,7 @@ The normal web PR pipeline is not the complete native release qualification surf | DOCX | `docx` + JSZip | Word-compatible export | | PWA | Service Worker + Web App Manifest | Offline shell/installability | | i18n | Custom React i18n context | 2963 keys × 19 locales | -| Testing | Vitest 5.x (8701+ tests / 655 files) + Playwright | Unit/integration/E2E | +| Testing | Vitest 5.x (8709+ tests / 655 files) + Playwright | Unit/integration/E2E | | Quality | Biome + tsgo + CodeQL/security tooling | Static and CI gates | | Desktop | Tauri 2 | Current native shell | @@ -884,7 +884,7 @@ WorldScript-Studio/ ├── locales/ # Source locale trees ├── public/ # PWA assets, manifest, SW, runtime locale bundles ├── tests/ -│ ├── unit/ # Vitest unit tests (the 8701+ total also includes components/ and packages/*/tests; tests/e2e is excluded) +│ ├── unit/ # Vitest unit tests (the 8709+ total also includes components/ and packages/*/tests; tests/e2e is excluded) │ └── e2e/ # Playwright ├── docs/ # Canonical product/engineering documentation + ADRs ├── src-tauri/ # Tauri v2 desktop shell / Rust @@ -1029,7 +1029,7 @@ Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendo Current source-synchronized README metrics: -- **8701+ unit tests** across **655 test files** +- **8709+ unit tests** across **655 test files** - i18n: **2963 keys × 19 locales** CI remains authoritative for actual pass/fail and live coverage. diff --git a/docs/CI.md b/docs/CI.md index a880f0253..6c1b7988e 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -12,10 +12,10 @@ For historical optimization notes (targets may predate the live workflow), see [ **Canonical quality gate:** GitHub Actions. Low-end local machines should **not** be expected to run the full heavy stack. -| Tier | Where | Commands / scope | -|------|--------|------------------| +| Tier | Where | Commands / scope | +| ----------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Quick (local)** | Developer laptop | `pnpm run ci:prepush` (change-aware: single-checker typecheck and i18n/content-guard checks run only when the outgoing change classification requires them, see [`ci:prepush` change-aware routing](#ciprepush-change-aware-routing); release/doc truth and lightweight guardrails run unconditionally); the pre-commit hook runs staged Biome checks; optional targeted `pnpm exec vitest run ` for a fast smoke | -| **Heavy (CI)** | `ci.yml` | Vitest **with** `--coverage` and thresholds, Playwright E2E (`CI=true`) including **mobile emulation** (Pixel 5 / Chromium), Lighthouse CI, Storybook static build, bundle budget + analyze. Mutation testing (Stryker) is **not** part of this pipeline — see [Mutation testing status](#mutation-testing-status). | +| **Heavy (CI)** | `ci.yml` | Vitest **with** `--coverage` and thresholds, Playwright E2E (`CI=true`) including **mobile emulation** (Pixel 5 / Chromium), Lighthouse CI, Storybook static build, bundle budget + analyze. Mutation testing (Stryker) is **not** part of this pipeline — see [Mutation testing status](#mutation-testing-status). | **Merge readiness:** A green workflow run on the PR/branch matters more than reproducing every E2E or LHCI step locally. Use CI **artifacts** (Playwright HTML report, coverage, Lighthouse output) to debug failures. @@ -65,17 +65,17 @@ coverage ratchet remains informational. ## Toolchain (CI parity) -| Requirement | Source | -|-------------|--------| -| Node.js | [`.nvmrc`](../.nvmrc) (currently **22**) | -| Package manager | **pnpm** 12.x, exact version pinned by [`package.json`](../package.json) `packageManager` | -| Lint / format | **Biome** (`pnpm run lint`, `lint:fix`) | -| i18n parity | **`pnpm run i18n:check`** — every locale must expose the same keys as `locales/en/*.json` (see [`scripts/check-i18n-keys.mjs`](../scripts/check-i18n-keys.mjs); optional `--fix` copies missing strings from EN) | -| Types | **TypeScript 7** (`tsgo`) `pnpm run typecheck` | -| Unit tests | **Vitest** with V8 coverage (`pnpm exec vitest run --coverage`) | -| E2E | **Playwright** (`pnpm run test:e2e` with `CI=true`) | +| Requirement | Source | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Node.js | [`.nvmrc`](../.nvmrc) (currently **22**) | +| Package manager | **pnpm** 12.x, exact version pinned by [`package.json`](../package.json) `packageManager` | +| Lint / format | **Biome** (`pnpm run lint`, `lint:fix`) | +| i18n parity | **`pnpm run i18n:check`** — every locale must expose the same keys as `locales/en/*.json` (see [`scripts/check-i18n-keys.mjs`](../scripts/check-i18n-keys.mjs); optional `--fix` copies missing strings from EN) | +| Types | **TypeScript 7** (`tsgo`) `pnpm run typecheck` | +| Unit tests | **Vitest** with V8 coverage (`pnpm exec vitest run --coverage`) | +| E2E | **Playwright** (`pnpm run test:e2e` with `CI=true`) | | Performance budgets | **Lighthouse CI** via `@lhci/cli` (`.lighthouserc.cjs`) — **accessibility** asserted at **`error`** level `minScore: 0.95` (blocks CI); performance `warn` ≥ 0.4; SEO `warn` ≥ 0.8; CLS `error` ≤ 0.1; FCP `warn` ≤ 5 s; LCP `warn` ≤ 7 s | -| Bundle guardrails | **`pnpm run bundle:budget`** (entry/vendor/JS/WASM ceilings) + **`pnpm run analyze`** (rollup visualizer → `dist/bundle-analysis.html`, artifact in CI) | +| Bundle guardrails | **`pnpm run bundle:budget`** (entry/vendor/JS/WASM ceilings) + **`pnpm run analyze`** (rollup visualizer → `dist/bundle-analysis.html`, artifact in CI) | --- @@ -136,36 +136,36 @@ scanned daily without running the full CI matrix. The existing `security` job re authoritative PR/main vulnerability gate; `pnpm audit` remains advisory because of its documented registry gzip-decoding failure mode, while OSV failures remain blocking. -| Job | Needs | Purpose | -|-----|--------|---------| -| `workflow-policy` | — | Structural (real-parser, not regex) validation of every `.github/workflows/*.yml` and `.github/actions/**/action.yml` — permissions, needs graph, SHA-pinned action references, publishing boundary (`scripts/workflow-policy-check.mjs`). Runs first, before any job invokes the governed `./.github/actions/setup` composite, using only external SHA-pinned actions directly (never that composite) and an `--ignore-scripts --ignore-pnpmfile` install, so a PR tampering with the composite (or its own install hooks) can't execute before the gate evaluates it. | -| `pr-size` | `workflow-policy` | PR-size governance (`scripts/check-pr-size.mjs`) — tiered file/line/commit limits, advisory below the absolute ceiling and blocking only above it. `pull_request` only. Runs the **base ref's** own copy of the checker (never the PR's working-tree copy) when it exists there, so a PR touching it can't raise its own limits; falls back to the PR's own copy one time only, for the introducing PR whose base ref has no checker yet. | -| `security` | `workflow-policy` | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` | -| `scheduled-osv` | — | Separate daily and manually triggerable (`workflow_dispatch`) `.github/workflows/security-scheduled.yml` scan of the same three lockfiles; `contents: read` only; fails closed and writes lockfile/package/advisory details to the step summary | -| `quality` | `security` | Matrix **Node 22** and **24** → Biome lint, **`pnpm run i18n:check`**, **`pnpm run docs:check`**, **`pnpm run csp:verify`**, **`pnpm run parity:check`**, `pnpm run typecheck`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), Codecov coverage (Node 22 only, optional token), Codecov Test Analytics per Node version (`unit-node22`/`unit-node24` flags), coverage + JUnit artifacts | -| `rust-tauri` | `security` | Rust `cargo fmt --check`, `cargo check --locked`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo test --locked` on stable, then `cargo +1.90 check --locked --all-targets` at the declared `rust-version` of `src-tauri` (the Core keeps its own 1.77.2 build in `secure-store-platform`); compile/lint signal for Tauri changes without building installers on every PR | -| `build` | `quality` | Production `pnpm run build`, **`bundle:budget`**, **`analyze`** (upload `bundle-analysis.html`; Codecov Bundle Analysis runs inside this same build — see below), **`pnpm run smoke:prod`** (headless-Chromium prod-build + CSP-runtime gate — see below), `dist` artifact; on `main` (non-PR): Pages artifact + **SLSA build provenance attestation**. No `if:` on the job itself — `smoke:prod` runs on every PR, not just `main` pushes. | -| `e2e` | `quality` | Playwright **Chromium** + **Mobile Chrome** (Pixel 5) — `CI=true`, 2× retries, 50 min timeout; browser cache via `actions/cache@v6.1.0`. Firefox optional locally. `PLAYWRIGHT_SKIP_VRT=true` (VRT runs in `browser-quality` instead). | -| `browser-quality` | `build` | **Consolidated (Wave 2 CI-performance)**: VRT then Lighthouse, sharing one checkout/setup/build-artifact download. VRT: visual regression against production `dist`, `toHaveScreenshot()` with committed PNG baselines (4 views × Chromium). Lighthouse (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** ≤ 0.1; performance/SEO warn — blocking. Lighthouse (desktop): accessibility/CLS are error-level LHCI assertions same as mobile and can fail the step itself, but the pre-existing `continue-on-error: true` (unchanged by this consolidation) keeps that failure from failing the job; promotion to blocking is gated on the 5-run exit criterion below. Each phase runs on `if: ${{ !cancelled() }}` relative to the others — a VRT or mobile-Lighthouse failure never skips a later phase's own evidence; only desktop Lighthouse's own failure is swallowed (job-level result). Artifacts uploaded always. Timeout 35 min. | -| `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v6.1.0`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` — see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. | -| `signatures` | `security` | Read-only GitHub API verification of every commit in the complete introduced range; pull-request commit pagination; and annotated release-tag plus target-commit verification. | -| `secure-store-platform` | `security`, `changes` | R-15 Gate 1b-platform evidence (matrix **Ubuntu**, **macOS**, **Windows**; only when `crates/` or its helper changed; deliberately uncached): clippy and guard tests with `--features platform-keystore`, the real OS secure-store lifecycle (Linux in an isolated D-Bus session with a throwaway gnome-keyring via `scripts/secret-service-session.sh`), and on Linux the fail-closed run without any Secret Service plus the Rust 1.77.2 MSRV build with the feature. | -| `ci-success` | `workflow-policy`, `pr-size`, `security`, `signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `secure-store-platform`, `build`, `e2e`, `browser-quality` | Required-status **aggregator** — `if: always()`, fails if any required release-safety job does not resolve to `success`; signature verification is authoritative; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched; `pr-size` is legitimately skipped only on non-`pull_request` events. | -| `deploy` | `ci-success` | **Only** `main` push (not PR), and only after the aggregate gate succeeds; the Pages artifact is resolved from the same workflow run. | +| Job | Needs | Purpose | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `workflow-policy` | — | Structural (real-parser, not regex) validation of every `.github/workflows/*.yml` and `.github/actions/**/action.yml` — permissions, needs graph, SHA-pinned action references, publishing boundary (`scripts/workflow-policy-check.mjs`). Runs first, before any job invokes the governed `./.github/actions/setup` composite, using only external SHA-pinned actions directly (never that composite) and an `--ignore-scripts --ignore-pnpmfile` install, so a PR tampering with the composite (or its own install hooks) can't execute before the gate evaluates it. | +| `pr-size` | `workflow-policy` | PR-size governance (`scripts/check-pr-size.mjs`) — tiered file/line/commit limits, advisory below the absolute ceiling and blocking only above it. `pull_request` only. Runs the **base ref's** own copy of the checker (never the PR's working-tree copy) when it exists there, so a PR touching it can't raise its own limits; falls back to the PR's own copy one time only, for the introducing PR whose base ref has no checker yet. | +| `security` | `workflow-policy` | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` | +| `scheduled-osv` | — | Separate daily and manually triggerable (`workflow_dispatch`) `.github/workflows/security-scheduled.yml` scan of the same three lockfiles; `contents: read` only; fails closed and writes lockfile/package/advisory details to the step summary | +| `quality` | `security` | Matrix **Node 22** and **24** → Biome lint, **`pnpm run i18n:check`**, **`pnpm run docs:check`**, **`pnpm run csp:verify`**, **`pnpm run parity:check`**, `pnpm run typecheck`, Vitest + coverage (+ non-blocking coverage-ratchet suggestion), Codecov coverage (Node 22 only, optional token), Codecov Test Analytics per Node version (`unit-node22`/`unit-node24` flags), coverage + JUnit artifacts | +| `rust-tauri` | `security` | Rust `cargo fmt --check`, `cargo check --locked`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo test --locked` on stable, then `cargo +1.90 check --locked --all-targets` at the declared `rust-version` of `src-tauri` (the Core keeps its own 1.77.2 build in `secure-store-platform`); compile/lint signal for Tauri changes without building installers on every PR | +| `build` | `quality` | Production `pnpm run build`, **`bundle:budget`**, **`analyze`** (upload `bundle-analysis.html`; Codecov Bundle Analysis runs inside this same build — see below), **`pnpm run smoke:prod`** (headless-Chromium prod-build + CSP-runtime gate — see below), `dist` artifact; on `main` (non-PR): Pages artifact + **SLSA build provenance attestation**. No `if:` on the job itself — `smoke:prod` runs on every PR, not just `main` pushes. | +| `e2e` | `quality` | Playwright **Chromium** + **Mobile Chrome** (Pixel 5) — `CI=true`, 2× retries, 50 min timeout; browser cache via `actions/cache@v6.1.0`. Firefox optional locally. `PLAYWRIGHT_SKIP_VRT=true` (VRT runs in `browser-quality` instead). | +| `browser-quality` | `build` | **Consolidated (Wave 2 CI-performance)**: VRT then Lighthouse, sharing one checkout/setup/build-artifact download. VRT: visual regression against production `dist`, `toHaveScreenshot()` with committed PNG baselines (4 views × Chromium). Lighthouse (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** ≤ 0.1; performance/SEO warn — blocking. Lighthouse (desktop): accessibility/CLS are error-level LHCI assertions same as mobile and can fail the step itself, but the pre-existing `continue-on-error: true` (unchanged by this consolidation) keeps that failure from failing the job; promotion to blocking is gated on the 5-run exit criterion below. Each phase runs on `if: ${{ !cancelled() }}` relative to the others — a VRT or mobile-Lighthouse failure never skips a later phase's own evidence; only desktop Lighthouse's own failure is swallowed (job-level result). Artifacts uploaded always. Timeout 35 min. | +| `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v6.1.0`; `--maxWorkers=2 --junit` (non-blocking, `continue-on-error: true` — see [exit criteria](#non-blocking-gates--exit-criteria-f-13)); artifacts uploaded always. Debug: manual `storybook-debug.yml` workflow. | +| `signatures` | `security` | Read-only GitHub API verification of every commit in the complete introduced range; pull-request commit pagination; and annotated release-tag plus target-commit verification. | +| `secure-store-platform` | `security`, `changes` | R-15 Gate 1b-platform evidence (matrix **Ubuntu**, **macOS**, **Windows**; only when `crates/` or its helper changed; deliberately uncached): clippy and guard tests with `--features platform-keystore`, the real OS secure-store lifecycle (Linux in an isolated D-Bus session with a throwaway gnome-keyring via `scripts/secret-service-session.sh`), and on Linux the fail-closed run without any Secret Service plus the Rust 1.77.2 MSRV build with the feature. | +| `ci-success` | `workflow-policy`, `pr-size`, `security`, `signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `secure-store-platform`, `build`, `e2e`, `browser-quality` | Required-status **aggregator** — `if: always()`, fails if any required release-safety job does not resolve to `success`; signature verification is authoritative; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched; `pr-size` is legitimately skipped only on non-`pull_request` events. | +| `deploy` | `ci-success` | **Only** `main` push (not PR), and only after the aggregate gate succeeds; the Pages artifact is resolved from the same workflow run. | > **Desktop:** On-demand / tag-driven Tauri bundles live in [`tauri-build.yml`](../.github/workflows/tauri-build.yml); **`v*` tags** additionally publish installers on a **GitHub Release**. See [`docs/TAURI-CI.md`](TAURI-CI.md). Desktop CI does not block the web deploy graph above. > -> **Release publication security gate (#911):** Tauri `release` (GitHub Release assets and the updater `latest.json`) and GHCR `build-push` (version, `major.minor` and `latest`, including metadata-action's `latest=auto`) each need a `security-audit` job inlined in the same workflow. That job: +> **Release publication security gate (#911):** Tauri `release` (GitHub Release assets and the updater `latest.json`) and GHCR `build-push` (version, `major.minor` and `latest`, including metadata-action's `latest=auto`) each need a `security-audit` job inlined in the same workflow. GHCR additionally requires a real push to a `v*` tag and successful exact-tag GitHub signature verification; `workflow_dispatch` only runs the Docker audit and cannot publish. That audit job: > 1. validates `EXPECTED_SHA: ${{ github.sha }}`; > 2. checks out exactly that commit and proves `HEAD` equals it; > 3. runs the same pinned OSV scanner and `src-tauri/osv-scanner.toml` over `pnpm-lock.yaml`, `src-tauri/Cargo.lock` and `crates/Cargo.lock`; > 4. only then emits `audited-sha`. > -> Each publish job's `if` requires `needs.security-audit.result == 'success'` and `needs.security-audit.outputs.audited-sha == github.sha`, with no status function. A finding, scanner or database error, skip, cancellation, timeout or SHA mismatch therefore leaves it unrun, for tag pushes and `workflow_dispatch` alike. +> Each actual publish job's `if` requires `needs.security-audit.result == 'success'` and `needs.security-audit.outputs.audited-sha == github.sha`, with no status function. Docker additionally requires `needs.verify-release-tag.result == 'success'`; that read-only job verifies the real annotated `v*` tag and its target commit through the repository's GitHub signature verifier. Docker's `workflow_dispatch` event may run the audit for diagnosis, but `build-push` remains skipped for every manual dispatch, including when the dispatched ref is a tag. > -> **Why inlined, and where it is enforced:** the trusted workflow-policy checker on `main` rejects local reusable-workflow calls, and a PR cannot change that trusted root, so the audit job is inlined. [`scripts/release-security-gate.mjs`](../scripts/release-security-gate.mjs) checks the gated jobs and the three identical audit jobs. Its unit tests run in the Quality Gate, with negative fixtures; promoting these rules into the trusted checker is an owner step. +> **Why inlined, and where it is enforced:** the trusted workflow-policy checker on `main` rejects local reusable-workflow calls, and a PR cannot change that trusted root, so the audit job is inlined. [`scripts/release-security-gate.mjs`](../scripts/release-security-gate.mjs) checks exact conjunctive publish conditions, the Docker tag-verification dependency, and the three identical audit jobs. Its unit tests run in the Quality Gate, with negative fixtures; promoting these rules into the trusted checker is an owner step. > -> **Live proof without publishing:** [`release-gate-proof.yml`](../.github/workflows/release-gate-proof.yml) is dispatch-only. Its publish stand-in uses the identical condition. +> **Live proof without publishing:** [`release-gate-proof.yml`](../.github/workflows/release-gate-proof.yml) is dispatch-only. Its echo-only stand-in uses explicit inputs to model a valid `push` to a `v*` tag, a successful tag-signature-verification result, and the exact audited SHA. Those inputs are a simulation: the workflow has no registry credentials or write permission and does not claim to verify a real tag. A manual dispatch, even with a tag ref, can never publish; the actual Docker workflow requires the real push event and verifier job. ### Codecov integration @@ -181,14 +181,14 @@ artifact upload already listed above). `if: ${{ !cancelled() }}`, not the default `success()`, so a real test failure still uploads its result instead of silently disappearing. -| Suite | Flag | JUnit source | -|-------|------|--------------| -| Unit (Node 22) | `unit-node22` | `reports/junit.xml` (Vitest, `--reporter=junit`) | -| Unit (Node 24) | `unit-node24` | `reports/junit.xml` (Vitest, `--reporter=junit`) | -| Required E2E | `e2e` | `tests/e2e/results/junit.xml` (Playwright) | -| Deep E2E (advisory) | `e2e-deep` | `tests/e2e/results/junit.xml`, same runner as above | -| Storybook (advisory) | `storybook` | `test-results/storybook-junit.xml` (`JEST_JUNIT_OUTPUT_FILE` pins the path — `--junit`'s own default, jest-junit's `/junit.xml`, was never captured by the existing artifact upload) | -| VRT | `vrt` | `tests/e2e/results/junit.xml`, same runner as E2E | +| Suite | Flag | JUnit source | +| -------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unit (Node 22) | `unit-node22` | `reports/junit.xml` (Vitest, `--reporter=junit`) | +| Unit (Node 24) | `unit-node24` | `reports/junit.xml` (Vitest, `--reporter=junit`) | +| Required E2E | `e2e` | `tests/e2e/results/junit.xml` (Playwright) | +| Deep E2E (advisory) | `e2e-deep` | `tests/e2e/results/junit.xml`, same runner as above | +| Storybook (advisory) | `storybook` | `test-results/storybook-junit.xml` (`JEST_JUNIT_OUTPUT_FILE` pins the path — `--junit`'s own default, jest-junit's `/junit.xml`, was never captured by the existing artifact upload) | +| VRT | `vrt` | `tests/e2e/results/junit.xml`, same runner as E2E | Vitest runs once per Node version with two reporters in the same invocation (`--reporter=json --reporter=junit`) — never a second test run merely to get JUnit; the `junit` @@ -252,11 +252,11 @@ that broke. See [ADR-0013](adr/0013-csp-wasm-and-blob-frames.md) for the full in **Layer A alone was never sufficient, and never will be**, for anything shaped like this defect — that's the standing lesson, not a one-time fix: -| Layer | Where | Checks | What it CANNOT catch | -|-------|-------|--------|-----------------------| -| **A — Consistency** | `tests/unit/csp.test.ts`, `tests/unit/deploymentHeaders.test.ts` | Cross-surface consistency — the 5 CSP surfaces agree with each other (identical strings / no header looser than the meta tag) | Whether the *agreed-upon* policy is itself correct — 5 identically-broken CSPs pass Layer A with a clean bill of health | -| **B — Correctness** | `tests/unit/cspCorrectness.test.ts` | Specific directives contain the tokens a shipped feature actually needs (`'wasm-unsafe-eval'`, `frame-src blob:`, …), forbidden tokens are absent (`'unsafe-eval'`, `'unsafe-inline'` in `script-src`), and every inline `