diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index fc35b93ba..b72247f89 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -5,7 +5,7 @@ name: Docker
on:
push:
- tags: ['v*']
+ tags: ["v*"]
workflow_dispatch:
# QNBS-v3: top-level read-only follows principle of least privilege (CodeQL Token-Permissions).
@@ -69,12 +69,49 @@ 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') && github.ref_protected == true }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ verified-tag-sha: ${{ steps.verify.outputs.verified-tag-sha }}
+ steps:
+ - name: Check out the pinned trusted verifier
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: 147470682b62d5bfe196bba803353d9f25cab8a7
+ path: trusted-verifier
+ fetch-depth: 1
+ persist-credentials: false
+ - name: Verify GitHub signatures for the release tag
+ id: verify
+ 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_SHA: ${{ github.sha }}
+ GITHUB_TOKEN: ${{ github.token }}
+ shell: bash
+ working-directory: trusted-verifier
+ run: >-
+ set -euo pipefail && env -i PATH="$PATH" 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_SHA="$GITHUB_SHA" GITHUB_TOKEN="$GITHUB_TOKEN"
+ node scripts/signing/verify-github-signatures.mjs &&
+ echo "verified-tag-sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
+
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.
- 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: >-
- ${{ 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') && github.ref_protected == true && needs.verify-release-tag.result == 'success' && needs.verify-release-tag.outputs.verified-tag-sha == github.sha && 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..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 (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; 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
@@ -13,6 +13,29 @@ 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
+ 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
@@ -77,7 +100,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') && 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/CHANGELOG.md b/CHANGELOG.md
index 3dab8eded..e73c7371a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+- **Container publishing:** GHCR releases now require a protected, signed annotated `v*` tag bound to the triggering commit, verified by a pinned source, and the exact-commit security audit; manual dispatch remains non-publishing (PR #1205).
- **Release-tag verification:** Validate protected-main ancestry with the GitHub Compare API's returned base and merge-base commits, and bind the verified tag target to the triggering event SHA (PR #1207).
diff --git a/README.md b/README.md
index ec6d206b5..6dfc2e1b8 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 (8705+ tests / 656 files) + Playwright | Unit/integration/E2E |
+| Testing | Vitest 5.x (8721+ tests / 656 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 8705+ total also includes components/ and packages/*/tests; tests/e2e is excluded)
+│ ├── unit/ # Vitest unit tests (the 8721+ 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:
-- **8705+ unit tests** across **656 test files**
+- **8721+ unit tests** across **656 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 `