Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,43 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
# ----------------------------------------------------------
# -1. WORKFLOW POLICY GATE: runs before any job invokes the governed
# ./.github/actions/setup composite (first used by "security" below), so a
# PR that tampers with that composite — or any other workflow/action file —
# is rejected before its own tampered code ever executes. Uses only
# external, SHA-pinned actions directly, never the composite it gates.
# ----------------------------------------------------------
workflow-policy:
Comment thread
qnbs marked this conversation as resolved.
name: 📜 Workflow Policy Gate
runs-on: ubuntu-latest
timeout-minutes: 10
Comment thread
qnbs marked this conversation as resolved.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
version: 11.22.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .nvmrc
cache: pnpm
# QNBS-v3: no lifecycle/.pnpmfile.cjs hooks — install must not run repo code before the gate.
- name: Install dependencies (no lifecycle scripts)
run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile
# QNBS-v3: node directly — pnpm run's own pre-run check rejects an --ignore-pnpmfile install.
- name: Workflow-policy structural gate (permissions, needs graph, action pins)
run: node scripts/workflow-policy-check.mjs
Comment thread
qnbs marked this conversation as resolved.

# ----------------------------------------------------------
# 0. SECURITY: pnpm audit + gitleaks + dependency review
# ----------------------------------------------------------
security:
name: 🔒 Security Audit
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [workflow-policy]
Comment thread
qnbs marked this conversation as resolved.
# QNBS-v3: dependency-review-action needs readable PR metadata — otherwise the job fails unnecessarily on external PRs/scopes.
permissions:
contents: read
Expand Down Expand Up @@ -138,7 +168,7 @@ jobs:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
Expand Down Expand Up @@ -442,12 +472,26 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
# QNBS-v3: Every unconditional job is either required here or explicitly advisory at job level.
needs: [security, signatures, quality, changes, rust-tauri, core-rust, build, e2e, lighthouse, vrt]
needs:
[
workflow-policy,
security,
signatures,
quality,
changes,
rust-tauri,
core-rust,
build,
e2e,
lighthouse,
vrt,
]
if: always()
steps:
- name: Verify all required jobs succeeded
run: |
FAIL=0
[ "${{ needs.workflow-policy.result }}" = "success" ] || FAIL=1
[ "${{ needs.security.result }}" = "success" ] || FAIL=1
[ "${{ needs.signatures.result }}" = "success" ] || FAIL=1
[ "${{ needs.quality.result }}" = "success" ] || FAIL=1
Expand All @@ -464,6 +508,7 @@ jobs:
[ "${{ needs.vrt.result }}" = "success" ] || FAIL=1
if [ "$FAIL" = "1" ]; then
echo "One or more required jobs did not succeed:"
echo " workflow-policy: ${{ needs.workflow-policy.result }}"
echo " security: ${{ needs.security.result }}"
echo " signatures: ${{ needs.signatures.result }}"
echo " quality: ${{ needs.quality.result }}"
Expand Down
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,11 +373,11 @@ procedure.
### Pipeline Graph

```
security ──► quality ──┬──► build ──► lighthouse
├──► e2e
├──► e2e-deep (non-blocking)
├──► storybook
└──► vrt
workflow-policy ──► security ──► quality ──┬──► build ──► lighthouse
├──► e2e
├──► e2e-deep (non-blocking)
├──► storybook
└──► vrt
build (main, non-PR) ──► upload-pages-artifact
deploy (main, non-PR) needs: ci-success ──► GitHub Pages
```
Expand All @@ -386,6 +386,7 @@ deploy (main, non-PR) needs: ci-success ──► GitHub Pages

| Job | Purpose |
|-----|---------|
| `workflow-policy` | Structural gate on `.github/workflows/*.yml` + `.github/actions/**/action.yml` (`scripts/workflow-policy-check.mjs`) — permissions, needs graph, SHA-pinned action references, publishing boundary. Runs first, before any job invokes the governed `./.github/actions/setup` composite; sets up pnpm/Node with external SHA-pinned actions directly instead of that composite. |
| `security` | `pnpm audit --audit-level=high`, OSV scanner (pnpm + `src-tauri/` + `crates/` Cargo lockfiles), gitleaks secrets scan, dependency review on PRs |
| `quality` | Node 22 + 24 matrix → Biome lint, suppression-debt ratchet, `i18n:check`, `docs:check`, `csp:verify`, `parity:check`, `tsgo --noEmit`, Storybook build, Vitest + coverage, Codecov upload |
| `rust-tauri` | `fmt`/`check`/`clippy`/`test` for `src-tauri/`; path-scoped (skips on PRs that don't touch it), needs GTK/WebKit apt-get steps |
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/Storage-IndexedDB_v8-F59E0B" alt="IndexedDB v8">
<img src="https://img.shields.io/badge/PWA-v3.0-5BB974?logo=pwa" alt="PWA v3.0">
<img src="https://img.shields.io/badge/i18n-19_locales-2925_keys-0EA5E9" alt="i18n 19 locales — 2925 keys">
<img src="https://img.shields.io/badge/Tests-7005%2B_%2F_578_files-22C55E" alt="7005+ tests / 578 files">
<img src="https://img.shields.io/badge/Tests-7076%2B_%2F_579_files-22C55E" alt="7076+ tests / 579 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="License MIT">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github" alt="CI Status">
Expand Down Expand Up @@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
| **Testing** | Vitest 4.x (7005+ tests / 578 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (7076+ tests / 579 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
Expand Down Expand Up @@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (7005+ tests, 578 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (7076+ tests, 579 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
Expand Down Expand Up @@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |

**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
- **7005+ unit tests** across **578 test files** — CI is authoritative for pass/fail
- **7076+ unit tests** across **579 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)

Expand Down
42 changes: 23 additions & 19 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ CI runs for the affected test path before removing a temporary quarantine.

### Gate authority

`✅ CI Success` is the required branch-protection status and aggregates `security`, `signatures`,
`quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and
`✅ CI Success` is the required branch-protection status and aggregates `workflow-policy`, `security`,
`signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and
`storybook` are explicitly advisory at job level while their stability criteria are measured. The
`deploy` job depends only on that aggregate and remains main-push-only.

Expand Down Expand Up @@ -83,6 +83,8 @@ pnpm/action-setup (explicit patched 11.22.0) → actions/setup-node (cache: pnpm

Each job that uses the composite must call `actions/checkout@v6` first (local composite actions are resolved from the workspace, so the repo must be checked out before `uses: ./.github/actions/setup` can be used). The `quality` job additionally passes `node-version: ${{ matrix.node-version }}` to cover the LTS matrix.

**`workflow-policy` deliberately does not use this composite.** It sets up pnpm/Node inline with the same external SHA-pinned actions instead, so a PR that tampers with `.github/actions/setup/action.yml` itself can't have that tampered content execute before `workflow-policy` evaluates it — see the job table above.

---

## Workflow triggers
Expand All @@ -98,21 +100,22 @@ Each job that uses the composite must call `actions/checkout@v6` first (local co
## Job graph

```text
security ──► quality ──┬──► build ──┬──► lighthouse
├──► e2e └──► vrt
├──► e2e-deep (advisory)
└──► storybook (advisory)

security ─┬
signatures ─┤
quality ──┼──► ci-success (required-status aggregator)
changes ──┤
rust ────┤
core-rust ┤
build ────┤
e2e ──────┤
lighthouse ─┤
vrt ──────┘
workflow-policy ──► security ──► quality ──┬──► build ──┬──► lighthouse
├──► e2e └──► vrt
├──► e2e-deep (advisory)
└──► storybook (advisory)

workflow-policy ─┐
security ────────┤
signatures ──────┤
quality ─────────┼──► ci-success (required-status aggregator)
changes ─────────┤
rust ────────────┤
core-rust ───────┤
build ───────────┤
e2e ─────────────┤
lighthouse ──────┤
vrt ─────────────┘

build (main, non-PR) ──► upload-pages-artifact
ci-success (main, non-PR) ──► deploy ──► GitHub Pages
Expand All @@ -127,7 +130,8 @@ registry gzip-decoding failure mode, while OSV failures remain blocking.

| Job | Needs | Purpose |
|-----|--------|---------|
| `security` | — | `pnpm audit --audit-level=high`; **OSV scanner** (`google/osv-scanner-action`) for npm + Rust lockfiles; `gitleaks` secrets scan; on PRs: `dependency-review-action` |
| `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. |
| `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 (optional token), coverage artifact |
| `rust-tauri` | `security` | Rust `cargo fmt --check`, `cargo check --locked`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo test --locked`; compile/lint signal for Tauri changes without building installers on every PR |
Expand All @@ -137,7 +141,7 @@ registry gzip-decoding failure mode, while OSV failures remain blocking.
| `storybook` | `quality` | Cloud-first — Storybook build + test-runner only run in CI (not locally); Playwright browser cache `v5`; `--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. |
| `vrt` | `build` | Visual regression against production `dist`; `toHaveScreenshot()` with committed PNG baselines (4 views × Chromium); artifacts uploaded always |
| `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. |
| `ci-success` | `security`, `signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, `vrt` | 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. |
| `ci-success` | `workflow-policy`, `security`, `signatures`, `quality`, `changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, `vrt` | 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. |
| `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.
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"csp:check": "node scripts/check-csp-policy.mjs",
"csp:verify": "node scripts/sync-csp.mjs && git diff --exit-code -- index.html nginx.conf public/_headers vercel.json src-tauri/tauri.conf.json && node scripts/check-csp-policy.mjs",
"native-readiness:check": "node scripts/check-native-readiness.mjs",
"workflow-policy:check": "node scripts/workflow-policy-check.mjs",
"suppressions:check": "node scripts/check-suppressions.mjs",
"token:audit": "node scripts/audit-tokens.mjs",
"guardrail:desktop-imports": "node scripts/check-tauri-import-boundary.mjs",
Expand Down Expand Up @@ -202,7 +203,8 @@
"vitest": "^4.1.10",
"wait-on": "^9.1.0",
"wrangler": "^4.120.1",
"y-protocols": "^1.0.7"
"y-protocols": "^1.0.7",
"yaml": "^2.9.0"
},
"simple-git-hooks": {
"pre-commit": "node scripts/hooks/pre-commit.mjs",
Expand Down
6 changes: 4 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions scripts/ci-prepush-check-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ const admissionCheckRegistry = Object.freeze([
'scripts/ci-prepush-range-resolver.mjs',
]),
},
{
name: 'workflowPolicy',
// QNBS-v3: composite actions carry the same uses:-pin risk as workflows themselves.
matches: (file) =>
file.startsWith('.github/workflows/') || file.startsWith('.github/actions/'),
implementationFiles: new Set([
routingAuthority,
runnerAuthority,
'scripts/ci-prepush-classifier.mjs',
'scripts/ci-prepush-range-resolver.mjs',
'scripts/workflow-policy-check.mjs',
]),
},
]);

export function shouldRunAdmissionCheck(name, files) {
Expand Down
3 changes: 3 additions & 0 deletions scripts/ci-prepush-lowend.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ async function main() {
if (shouldRunAdmissionCheck('contentGuard', classification.files) || full)
await runCheck('Content guard', () => runNodeScript('scripts/content-guard.mjs'));

if (shouldRunAdmissionCheck('workflowPolicy', classification.files) || full)
await runCheck('Workflow policy', () => runNodeScript('scripts/workflow-policy-check.mjs'));

if (typecheckRequired) {
await runCheck('TypeScript (single checker)', () =>
// QNBS-v3: one checker bounds memory use on constrained developer machines.
Expand Down
Loading
Loading