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
24 changes: 22 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,24 @@ jobs:
echo "crates=false" >> "$GITHUB_OUTPUT"
fi

signatures:
name: 🔏 Verified Signatures
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [security]
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
fetch-depth: 0
persist-credentials: false
- name: Verify every introduced GitHub commit and release tag
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/signing/verify-github-signatures.mjs

# ----------------------------------------------------------
# 1. QUALITY GATE: Lint + Typecheck + Tests (parallel matrix)
# ----------------------------------------------------------
Expand Down Expand Up @@ -417,20 +435,21 @@ jobs:

# ----------------------------------------------------------
# 3. CI SUCCESS: single required-status aggregator
# (security + quality + changes + rust-tauri + core-rust + build + e2e + vrt)
# (security + signatures + quality + changes + rust-tauri + core-rust + build + e2e + vrt)
# ----------------------------------------------------------
ci-success:
name: ✅ CI Success
runs-on: ubuntu-latest
timeout-minutes: 5
# QNBS-v3: Every unconditional job is either required here or explicitly advisory at job level.
needs: [security, quality, changes, rust-tauri, core-rust, build, e2e, lighthouse, vrt]
needs: [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.security.result }}" = "success" ] || FAIL=1
[ "${{ needs.signatures.result }}" = "success" ] || FAIL=1
[ "${{ needs.quality.result }}" = "success" ] || FAIL=1
[ "${{ needs.changes.result }}" = "success" ] || FAIL=1
if [ "${{ needs.rust-tauri.result }}" != "success" ] && [ "${{ needs.rust-tauri.result }}" != "skipped" ]; then
Expand All @@ -446,6 +465,7 @@ jobs:
if [ "$FAIL" = "1" ]; then
echo "One or more required jobs did not succeed:"
echo " security: ${{ needs.security.result }}"
echo " signatures: ${{ needs.signatures.result }}"
echo " quality: ${{ needs.quality.result }}"
echo " changes: ${{ needs.changes.result }}"
echo " rust-tauri: ${{ needs.rust-tauri.result }} (skipped = OK, src-tauri untouched)"
Expand Down
17 changes: 17 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,23 @@ and invoke local binaries without pnpm's workspace-state preflight in the hook p
The pre-commit hook is not a substitute for the complete pre-push gate; CI remains mandatory when
hooks are not installed.

### Verified signing cutover

`required_signatures` remains enabled on `main`. Before creating or pushing new history, run
`pnpm run signing:doctor` and install the hooks with `pnpm run hooks:install`. The pre-commit hook
fails closed when the effective signing configuration cannot create and Git-verify a signed commit;
the pre-push hook verifies every commit introduced by every ref update and verifies both annotated
release tags and their target commits. CI verifies GitHub's `commit.verification.verified` result
for the complete introduced range and includes that gate in `✅ CI Success`.

Local Git verification and GitHub Verified status are distinct: a local `git verify-commit` pass is
necessary but cannot establish GitHub account/key association. Never use `--no-gpg-sign`, `--no-verify`,
unsigned temporary commits, or unsigned release tags as recovery. Squash merges create a new signed
result and do not rewrite or retroactively verify legacy unsigned source commits. Worktree-local Git
configuration overrides repository and global configuration; the doctor reports unsafe environment
overrides. See [`docs/VERIFIED-SIGNING.md`](docs/VERIFIED-SIGNING.md) for the recovery and audit
procedure.

---

## Testing Instructions
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,3 +511,12 @@ The tool will automatically appear in `WriterView` if added to the tool list in
## License

[MIT](LICENSE)
## Signed commits and tags

New commits, outgoing pushes, and release tags must be signed. Run `pnpm run signing:doctor`
before installing hooks or when a signing operation fails, then install the repository hooks with
`pnpm run hooks:install`. The hooks reject missing or invalid local signatures; CI additionally
requires GitHub's `verification.verified` result for every introduced commit and for both an
annotated tag and its target commit. Do not bypass these checks with `--no-gpg-sign`, `--no-verify`,
or unsigned fallback objects. See [`docs/VERIFIED-SIGNING.md`](docs/VERIFIED-SIGNING.md) for
configuration precedence, recovery, and squash-history semantics.
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,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-6937%2B_%2F_573_files-22C55E" alt="6937+ tests / 573 files">
<img src="https://img.shields.io/badge/Tests-6947%2B_%2F_574_files-22C55E" alt="6947+ tests / 574 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 @@ -511,7 +511,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 (6937+ tests / 573 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Testing** | Vitest 4.x (6947+ tests / 574 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 @@ -549,7 +549,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
│ ├── unit/ # Vitest unit tests (6937+ tests, 573 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ ├── unit/ # Vitest unit tests (6947+ tests, 574 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 @@ -711,7 +711,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):**
- **6937+ unit tests** across **573 test files** — CI is authoritative for pass/fail
- **6947+ unit tests** across **574 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
8 changes: 5 additions & 3 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`, `quality`,
`changes`, `rust-tauri`, `core-rust`, `build`, `e2e`, `lighthouse`, and `vrt`. `e2e-deep` and
`✅ 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
`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 @@ -104,6 +104,7 @@ security ──► quality ──┬──► build ──┬──► lighthous
└──► storybook (advisory)

security ─┬
signatures ─┤
quality ──┼──► ci-success (required-status aggregator)
changes ──┤
rust ────┤
Expand Down Expand Up @@ -135,7 +136,8 @@ registry gzip-decoding failure mode, while OSV failures remain blocking.
| `lighthouse` | `build` | LHCI (mobile): **accessibility error gate** `minScore: 0.95`; **CLS error** ≤ 0.1; performance/SEO warn. Desktop run: `continue-on-error: true` until baselines stabilise. Timeout 25 min. |
| `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 |
| `ci-success` | `security`, `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`; Storybook and deep-E2E are explicitly advisory. Rust jobs are legitimately skipped when their paths are untouched. |
| `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. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `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
71 changes: 71 additions & 0 deletions docs/VERIFIED-SIGNING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Verified signing policy

WorldScript Studio requires signed commit objects at the local hook boundary and requires
GitHub's `commit.verification.verified == true` result at the CI boundary. These are related
but different checks:

- `git verify-commit` proves that the local Git installation can validate the signature object
against its configured trust/key policy. It does not prove that GitHub will associate the
commit with a verified account.
- GitHub's `verification.verified` result is the release and merge gate. It covers GitHub's
signature parser, key association, and account identity rules.
- Annotated release tags have two objects to verify: the tag object and its target commit.
Lightweight release tags are rejected because they have no independently verifiable tag
object; a verified target commit alone is insufficient for a release tag.

## Local setup and recovery

Run `pnpm run signing:doctor` after configuring a signing key. The doctor performs a
plumbing-level `git commit-tree -S` probe in an isolated temporary repository and validates the
result with Git-native verification. It never invokes normal repository hooks, reads private key
material, or prints signatures. `pnpm run hooks:install` installs the fail-closed pre-commit and
pre-push wrappers.

If a hook rejects a commit or push:

1. Run the doctor and correct the reported effective configuration, identity, key availability,
or trust/allowed-signers configuration.
2. Re-run the exact failed operation. Never use `--no-gpg-sign`, `--no-verify`, an unsigned
temporary commit, or an unsigned tag as a recovery path.
3. Use `pnpm run signing:check-range -- before..after` to inspect an exact local range.
4. For a pull request, use `pnpm run signing:verify-remote -- <owner>/<repo> <number>` when a
GitHub token is available; the CI gate remains authoritative.

The hook reads effective Git configuration, including repository and worktree configuration.
Repository-local and worktree-local values override global values, and environment-provided Git
configuration can override all of them. The doctor reports unsafe configuration overrides rather
than silently accepting them. Configure only a public signing-key reference where possible;
never copy, print, commit, or place private key material in the repository.

## History and squash semantics

The signing cutover applies to every new commit, push, release tag, and GitHub merge result. It
does not rewrite legacy history. A squash merge creates a new signed commit whose tree contains
the reviewed change; it does not make the unsigned source commits in the old branch signed or
erase their historical verification state. A historical audit is evidence for migration planning,
not a release waiver for new unsigned objects.

CI verifies the complete `before..after` range for branch pushes and the complete paginated PR
commit list for pull requests. Public-fork pull requests use read-only GitHub API access. API
errors, missing pages, missing verification data, invalid signatures, unsigned commits, and
unverified tag objects fail closed.

## Historical audit snapshot

The audit below is generated against `main` with GitHub's verification result, not merely local
Git trust. Dates are UTC and the interval is inclusive of commits reachable from `main` whose
committer timestamp is within the stated trailing window. Keep the command output or API response
with the release evidence when refreshing these figures.

| Window ending 2026-08-23 UTC | Verified | Unverified | Total |
| --- | ---: | ---: | ---: |
| 7 days | 89 | 0 | 89 |
| 14 days | 124 | 0 | 124 |
| 30 days | 178 | 0 | 178 |

This snapshot intentionally distinguishes the signed squash result from the source history that
preceded it. Open and merged PR source histories should be audited separately when investigating
legacy unsigned commits. The counts were collected on 2026-08-23 with GitHub's REST commits API,
`sha=main`, at collection time `2026-08-23T00:15:24Z`, with cutoff
`2026-08-23T00:15:24Z` and trailing UTC windows, using the `commit.verification.verified`
boolean; they are not inferred from local Git trust.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
},
"scripts": {
"hooks:install": "simple-git-hooks",
"signing:doctor": "node scripts/signing/doctor.mjs",
"signing:check-range": "node scripts/signing/check-range.mjs",
"signing:verify-remote": "node scripts/signing/verify-remote.mjs",
"toolchain:check": "node scripts/check-pnpm-toolchain.mjs",
"deps:verify": "node scripts/dependency-state.mjs verify",
"deps:reconcile": "node scripts/dependency-state.mjs reconcile",
Expand Down Expand Up @@ -203,7 +206,7 @@
},
"simple-git-hooks": {
"pre-commit": "node scripts/hooks/pre-commit.mjs",
"pre-push": "node scripts/hooks/pre-push.mjs"
"pre-push": "node scripts/hooks/pre-push.mjs \"$@\""
},
"lint-staged": {
"*.{ts,tsx,js,mjs,css,md,json}": "biome check --write --error-on-warnings --no-errors-on-unmatched --files-ignore-unknown=true",
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-tauri-import-boundary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const IGNORE_DIRS = new Set([
'storybook-static',
'coverage',
'.git',
// QNBS-v3: linked worktrees are separate checkout artifacts, not application source.
'.worktrees',
'reports',
'graphify-out',
'.codegraph',
Expand Down
3 changes: 2 additions & 1 deletion scripts/hooks/pre-commit.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import process from 'node:process';
import { ensureDependencyState, runLocalBinary } from './shared.mjs';
import { ensureDependencyState, runLocalBinary, runNodeScript } from './shared.mjs';

if (runNodeScript('scripts/signing/doctor.mjs', ['--hook']) !== 0) process.exit(1);
if (!ensureDependencyState()) process.exit(1);
process.exit(runLocalBinary('lint-staged'));
2 changes: 2 additions & 0 deletions scripts/hooks/pre-push.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import process from 'node:process';
import { runNodeScript } from './shared.mjs';

if (runNodeScript('scripts/signing/verify-outgoing.mjs', process.argv.slice(2)) !== 0)
process.exit(1);
process.exit(runNodeScript('scripts/ci-prepush-lowend.mjs'));
22 changes: 22 additions & 0 deletions scripts/signing/check-range.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env node
import { verifyCommitRange } from './signing-core.mjs';

const range = process.argv[2];
if (!range) {
console.error('usage: pnpm run signing:check-range -- <before..after>');
process.exit(2);
}
try {
const reports = verifyCommitRange(range);
for (const report of reports) {
console.log(
`${report.sha.slice(0, 12)} ${report.verification.ok ? 'verified' : 'REJECTED'} ${report.verification.reason} ${report.subject}`,
);
}
process.exit(reports.every((report) => report.verification.ok) ? 0 : 1);
} catch (error) {
console.error(
`signing range check failed: ${error instanceof Error ? error.message : 'invalid Git range'}`,
);
process.exit(1);
}
48 changes: 48 additions & 0 deletions scripts/signing/doctor.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env node
import {
getIdentity,
getSigningConfig,
getUnsafeOverrides,
isGitHubCompatibleEmail,
isSigningEnabled,
runSigningProbe,
safeConfigSummary,
} from './signing-core.mjs';

const jsonMode = process.argv.includes('--json');
const cwd = process.cwd();
const signing = getSigningConfig(cwd);
const identity = getIdentity(cwd);
const unsafeOverrides = getUnsafeOverrides();
const probe = runSigningProbe(cwd);
Comment thread
qnbs marked this conversation as resolved.
const summary = {
...safeConfigSummary(cwd),
probe: { ok: probe.ok, reason: probe.reason ?? 'signed probe verified' },
};

if (jsonMode) {
process.stdout.write(`${JSON.stringify(summary)}\n`);
} else {
console.log(`signing format: ${signing.format}`);
console.log(`commit.gpgsign: ${isSigningEnabled(signing.config) ? 'enabled' : 'disabled'}`);
console.log(`signing key: ${signing.keyConfigured ? 'configured' : 'missing'}`);
console.log(
`identity: ${identity.name && identity.email ? 'configured' : 'missing'} (${isGitHubCompatibleEmail(identity.email) ? 'GitHub noreply-compatible' : 'GitHub identity requires account verification'})`,
);
console.log(
`hooks: ${summary.hooks.pathConfigured ? 'custom path configured' : 'default path'} (${summary.hooks.hooksInstalled ? 'available' : 'not installed'})`,
);
console.log(
`unsafe config overrides: ${unsafeOverrides.length ? unsafeOverrides.join(', ') : 'none detected'}`,
);
console.log(`isolated signing probe: ${probe.ok ? 'passed' : `failed — ${probe.reason}`}`);
}

const hookFailure =
!signing.enabled ||
!signing.keyConfigured ||
!identity.name ||
!identity.email ||
unsafeOverrides.length > 0 ||
!probe.ok;
process.exit(hookFailure ? 1 : 0);
Loading
Loading