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 @@
-
+
@@ -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 @@
-
+
@@ -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 `