diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c8cf3f757..818c72f25 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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)
# ----------------------------------------------------------
@@ -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
@@ -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)"
diff --git a/AGENTS.md b/AGENTS.md
index 39f51993f..eb431789d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ab55f31cf..f44c75f49 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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.
diff --git a/README.md b/README.md
index 0410afb73..8c23e8a8e 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-
+
@@ -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` |
@@ -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
@@ -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)
diff --git a/docs/CI.md b/docs/CI.md
index eaaee91b9..b65d740bb 100644
--- a/docs/CI.md
+++ b/docs/CI.md
@@ -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.
@@ -104,6 +104,7 @@ security โโโบ quality โโโฌโโโบ build โโโฌโโโบ lighthous
โโโโบ storybook (advisory)
security โโฌ
+signatures โโค
quality โโโผโโโบ ci-success (required-status aggregator)
changes โโโค
rust โโโโโค
@@ -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. |
| `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.
diff --git a/docs/VERIFIED-SIGNING.md b/docs/VERIFIED-SIGNING.md
new file mode 100644
index 000000000..527f95995
--- /dev/null
+++ b/docs/VERIFIED-SIGNING.md
@@ -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 -- / ` 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.
diff --git a/package.json b/package.json
index e271365aa..708bb32d9 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -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",
diff --git a/scripts/check-tauri-import-boundary.mjs b/scripts/check-tauri-import-boundary.mjs
index 636e99f8b..8409d0f0a 100644
--- a/scripts/check-tauri-import-boundary.mjs
+++ b/scripts/check-tauri-import-boundary.mjs
@@ -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',
diff --git a/scripts/hooks/pre-commit.mjs b/scripts/hooks/pre-commit.mjs
index 8ce3f04e3..9a6ef9eec 100644
--- a/scripts/hooks/pre-commit.mjs
+++ b/scripts/hooks/pre-commit.mjs
@@ -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'));
diff --git a/scripts/hooks/pre-push.mjs b/scripts/hooks/pre-push.mjs
index 13582888e..5c8f3aa39 100644
--- a/scripts/hooks/pre-push.mjs
+++ b/scripts/hooks/pre-push.mjs
@@ -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'));
diff --git a/scripts/signing/check-range.mjs b/scripts/signing/check-range.mjs
new file mode 100644
index 000000000..ffa620613
--- /dev/null
+++ b/scripts/signing/check-range.mjs
@@ -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 -- ');
+ 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);
+}
diff --git a/scripts/signing/doctor.mjs b/scripts/signing/doctor.mjs
new file mode 100644
index 000000000..c2ce4d5f5
--- /dev/null
+++ b/scripts/signing/doctor.mjs
@@ -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);
+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);
diff --git a/scripts/signing/signing-core.d.mts b/scripts/signing/signing-core.d.mts
new file mode 100644
index 000000000..715a9d42c
--- /dev/null
+++ b/scripts/signing/signing-core.d.mts
@@ -0,0 +1,122 @@
+export interface VerificationResult {
+ ok: boolean;
+ reason: string;
+}
+
+export interface GitResult {
+ status: number;
+ stdout: string;
+ stderr: string;
+ error?: Error;
+}
+
+export interface RefUpdate {
+ localRef: string;
+ localSha: string;
+ remoteRef: string;
+ remoteSha: string;
+}
+
+export interface SigningConfig {
+ format: string;
+ keyConfigured: boolean;
+ gpgProgramConfigured: boolean;
+ allowedSignersConfigured: boolean;
+ enabled: boolean;
+ keyDisplay: string;
+ allowedSignersFile: string;
+ config: Record;
+}
+
+export interface GitOptions {
+ cwd?: string;
+ input?: string;
+}
+
+export interface OutgoingReport {
+ sha: string;
+ subject: string;
+ verification: VerificationResult;
+}
+
+export interface OutgoingResult {
+ ok: boolean;
+ reports?: OutgoingReport[];
+ reason: string;
+}
+
+export function runGit(args: string[], options?: GitOptions): GitResult;
+export function gitOutput(args: string[], options?: GitOptions): string;
+export function isSha(value: unknown): boolean;
+export function isZeroSha(value: unknown): boolean;
+export function getRepositoryRoot(cwd?: string): string | null;
+export function getGitDirectory(cwd?: string): string | null;
+export function getConfig(cwd?: string): Record;
+export function getIdentity(cwd?: string): { name: string; email: string };
+export function getUnsafeOverrides(env?: Record): string[];
+export function isSigningEnabled(config: Record): boolean;
+
+export function classifyCommitObject(input: {
+ objectType: string;
+ contents: string;
+ verificationStatus: number;
+}): VerificationResult;
+export function classifyTagVerification(input: {
+ objectType: string;
+ targetType: string;
+ tagVerificationStatus: number;
+ commitVerification: VerificationResult;
+}): VerificationResult;
+export function getSigningConfig(cwd?: string): SigningConfig;
+export function hasCommitSignature(commitText: string): boolean;
+export function isGitHubCompatibleEmail(email: string): boolean;
+export function parseRefUpdate(line: string): RefUpdate | null;
+export function selectIntroducedCommits(commits: string[], reachableFromBase: string[]): string[];
+export function runSigningProbe(cwd?: string): { ok: boolean; reason?: string; commit?: string };
+export function verifyCommitObject(sha: string, cwd?: string): VerificationResult;
+export function commitSubject(sha: string, cwd?: string): string;
+export function commitsInRange(range: string, cwd?: string): string[];
+export function verifyCommitRange(
+ range: string,
+ cwd?: string,
+): Array<{ sha: string; subject: string; verification: VerificationResult }>;
+export function pushEventRange(payload: { before?: string; after?: string }): {
+ before: string;
+ after: string;
+};
+export function pushCommitShas(
+ payload: { before?: string; after?: string },
+ cwd?: string,
+ rangeResolver?: (range: string, cwd?: string) => string[],
+): string[];
+export function parseAnnotatedTag(
+ sha: string,
+ cwd?: string,
+): { objectType: 'commit'; target: string } | { objectType: 'tag'; target: string; targetType: string } | null;
+export function verifyTagObject(sha: string, cwd?: string): VerificationResult;
+export function remoteTrackingBases(remote: string, remoteRef: string, cwd?: string): string[];
+export function outgoingBaseShas(update: RefUpdate, fallbackBases: string[]): string[];
+export function introducedCommits(update: RefUpdate, remote: string, cwd?: string): string[];
+export function safeConfigSummary(cwd?: string): {
+ signing: {
+ format: string;
+ enabled: boolean;
+ keyConfigured: boolean;
+ keyDisplay: string;
+ gpgProgramConfigured: boolean;
+ allowedSignersConfigured: boolean;
+ };
+ identity: { nameConfigured: boolean; emailConfigured: boolean; githubCompatible: boolean };
+ hooks: { pathConfigured: boolean; directory: string; hooksInstalled: boolean };
+ unsafeOverrides: string[];
+};
+export function verifyOutgoingUpdates(
+ lines: string[],
+ remote: string,
+ cwd?: string,
+ dependencies?: {
+ verifyCommitObject?: (sha: string) => VerificationResult;
+ verifyTagObject?: (sha: string) => VerificationResult;
+ introducedCommits?: (update: RefUpdate) => string[];
+ },
+): OutgoingResult;
diff --git a/scripts/signing/signing-core.mjs b/scripts/signing/signing-core.mjs
new file mode 100644
index 000000000..64b28b5b8
--- /dev/null
+++ b/scripts/signing/signing-core.mjs
@@ -0,0 +1,407 @@
+import { spawnSync } from 'node:child_process';
+import { existsSync, mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { basename, join, resolve } from 'node:path';
+
+const SHA = /^[0-9a-f]{40}$/i;
+const ZERO_SHA = /^0{40}$/;
+const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
+
+export function runGit(args, { cwd = process.cwd(), input = '' } = {}) {
+ const result = spawnSync('git', args, {
+ cwd,
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ GIT_TERMINAL_PROMPT: '0',
+ SSH_ASKPASS: '/bin/false',
+ SSH_ASKPASS_REQUIRE: 'force',
+ },
+ input,
+ timeout: 5000,
+ killSignal: 'SIGTERM',
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ return {
+ status: result.status ?? 1,
+ stdout: result.stdout ?? '',
+ stderr: result.stderr ?? '',
+ error: result.error,
+ };
+}
+
+export function gitOutput(args, options = {}) {
+ const result = runGit(args, options);
+ return result.status === 0 ? result.stdout.trim() : '';
+}
+
+export function isSha(value) {
+ return SHA.test(value);
+}
+
+export function isZeroSha(value) {
+ return ZERO_SHA.test(value);
+}
+
+export function getRepositoryRoot(cwd = process.cwd()) {
+ const root = gitOutput(['rev-parse', '--show-toplevel'], { cwd });
+ return root ? resolve(root) : null;
+}
+
+export function getGitDirectory(cwd = process.cwd()) {
+ const gitDir = gitOutput(['rev-parse', '--git-dir'], { cwd });
+ return gitDir ? resolve(cwd, gitDir) : null;
+}
+
+export function getConfig(cwd = process.cwd()) {
+ const keys = [
+ 'user.email',
+ 'user.name',
+ 'user.signingkey',
+ 'commit.gpgsign',
+ 'gpg.format',
+ 'gpg.program',
+ 'gpg.ssh.allowedSignersFile',
+ 'core.hooksPath',
+ ];
+ const values = Object.fromEntries(
+ keys.map((key) => [key, gitOutput(['config', '--get', key], { cwd })]),
+ );
+ return values;
+}
+
+export function getIdentity(cwd = process.cwd()) {
+ const config = getConfig(cwd);
+ return { name: config['user.name'], email: config['user.email'] };
+}
+
+export function isGitHubCompatibleEmail(email) {
+ return /^\d+\+[^@\s]+@users\.noreply\.github\.com$/i.test(email);
+}
+
+export function getUnsafeOverrides(env = process.env) {
+ const names = [
+ 'GIT_CONFIG_NOSYSTEM',
+ 'GIT_CONFIG_SYSTEM',
+ 'GIT_CONFIG_GLOBAL',
+ 'GIT_CONFIG_COUNT',
+ 'GIT_CONFIG_PARAMETERS',
+ ];
+ return names.filter((name) => env[name] !== undefined).map((name) => name);
+}
+
+export function isSigningEnabled(config) {
+ return /^(true|yes|on|1)$/i.test(config['commit.gpgsign'] ?? '');
+}
+
+export function getSigningConfig(cwd = process.cwd()) {
+ const config = getConfig(cwd);
+ const allowedSignersFile = config['gpg.ssh.allowedSignersFile']
+ ? gitOutput(['config', '--path', '--get', 'gpg.ssh.allowedSignersFile'], { cwd })
+ : '';
+ return {
+ format: config['gpg.format'] || 'openpgp',
+ keyConfigured: Boolean(config['user.signingkey']),
+ gpgProgramConfigured: Boolean(config['gpg.program']),
+ allowedSignersConfigured: Boolean(config['gpg.ssh.allowedSignersFile']),
+ enabled: isSigningEnabled(config),
+ keyDisplay: config['user.signingkey'] ? basename(config['user.signingkey']) : '',
+ allowedSignersFile,
+ config,
+ };
+}
+
+function configureProbeRepository(repo, signing, identity) {
+ const settings = [
+ ['user.name', identity.name || 'WorldScript signing probe'],
+ ['user.email', identity.email || 'signing-probe@users.noreply.github.com'],
+ ['commit.gpgsign', 'true'],
+ ['core.hooksPath', join(repo, 'hooks-disabled')],
+ ];
+ if (signing.config['gpg.format']) settings.push(['gpg.format', signing.config['gpg.format']]);
+ if (signing.config['user.signingkey'])
+ settings.push(['user.signingkey', signing.config['user.signingkey']]);
+ if (signing.config['gpg.program']) settings.push(['gpg.program', signing.config['gpg.program']]);
+ if (signing.allowedSignersFile)
+ settings.push(['gpg.ssh.allowedSignersFile', signing.allowedSignersFile]);
+ for (const [key, value] of settings) {
+ const result = runGit(['config', key, value], { cwd: repo });
+ if (result.status !== 0) return false;
+ }
+ return true;
+}
+
+function signingFailureReason(stderr) {
+ const message = stderr.toLowerCase();
+ if (message.includes('passphrase')) return 'signing key requires an unavailable passphrase';
+ if (message.includes('no such file') || message.includes('cannot open'))
+ return 'signing key or signer program is unavailable';
+ return 'Git signing command failed';
+}
+
+export function runSigningProbe(cwd = process.cwd()) {
+ const signing = getSigningConfig(cwd);
+ const identity = getIdentity(cwd);
+ if (!signing.enabled) return { ok: false, reason: 'commit.gpgsign is not enabled' };
+ if (!signing.keyConfigured) return { ok: false, reason: 'no signing key is configured' };
+ if (!identity.name || !identity.email)
+ return { ok: false, reason: 'user.name and user.email are required' };
+
+ let probe;
+ try {
+ probe = mkdtempSync(join(tmpdir(), 'worldscript-signing-probe-'));
+ } catch (error) {
+ if (error?.code !== 'EROFS' && error?.code !== 'EACCES') throw error;
+ probe = mkdtempSync(join(cwd, '.worldscript-signing-probe-'));
+ }
+ try {
+ let result = runGit(['init', '--quiet', '--initial-branch=main', probe]);
+ if (result.status !== 0 || !configureProbeRepository(probe, signing, identity)) {
+ return { ok: false, reason: 'isolated probe repository could not be configured' };
+ }
+ result = runGit(
+ ['commit-tree', '-S', '-m', 'WorldScript signing capability probe', EMPTY_TREE],
+ {
+ cwd: probe,
+ },
+ );
+ if (result.status !== 0 || !isSha(result.stdout.trim())) {
+ return {
+ ok: false,
+ reason: `plumbing-level signed commit could not be created: ${signingFailureReason(result.stderr)}`,
+ };
+ }
+ const commit = result.stdout.trim();
+ const verification = verifyCommitObject(commit, probe);
+ return verification.ok
+ ? { ok: true, commit }
+ : { ok: false, reason: `Git-native verification failed: ${verification.reason}` };
+ } finally {
+ rmSync(probe, { recursive: true, force: true });
+ }
+}
+
+export function hasCommitSignature(commitText) {
+ return /(^|\n)gpgsig /.test(commitText);
+}
+
+export function classifyCommitObject({ objectType, contents, verificationStatus }) {
+ if (objectType !== 'commit') return { ok: false, reason: 'object is not a commit' };
+ if (!hasCommitSignature(contents)) return { ok: false, reason: 'commit has no signature object' };
+ if (verificationStatus !== 0)
+ return { ok: false, reason: 'Git-native signature verification failed' };
+ return { ok: true, reason: 'Git-native signature verified' };
+}
+
+export function verifyCommitObject(sha, cwd = process.cwd()) {
+ if (!isSha(sha)) return { ok: false, reason: 'invalid commit SHA' };
+ const object = runGit(['cat-file', '-t', sha], { cwd });
+ const contents = runGit(['cat-file', '-p', sha], { cwd });
+ const verified = runGit(['verify-commit', '--raw', sha], { cwd });
+ return classifyCommitObject({
+ objectType: object.status === 0 ? object.stdout.trim() : '',
+ contents: contents.status === 0 ? contents.stdout : '',
+ verificationStatus: verified.status,
+ });
+}
+
+export function commitSubject(sha, cwd = process.cwd()) {
+ return gitOutput(['show', '-s', '--format=%s', sha], { cwd }) || '(subject unavailable)';
+}
+
+export function commitsInRange(range, cwd = process.cwd()) {
+ if (!range || /\s/.test(range)) throw new Error('range must be one Git revision range');
+ const result = runGit(['rev-list', '--reverse', range], { cwd });
+ if (result.status !== 0) throw new Error('requested Git range is not available');
+ return result.stdout.split(/\r?\n/).filter(Boolean);
+}
+
+export function verifyCommitRange(range, cwd = process.cwd()) {
+ return commitsInRange(range, cwd).map((sha) => ({
+ sha,
+ subject: commitSubject(sha, cwd),
+ verification: verifyCommitObject(sha, cwd),
+ }));
+}
+
+export function pushEventRange(payload) {
+ const before = payload?.before;
+ const after = payload?.after;
+ if (!isSha(before)) throw new Error('push event is missing an exact before SHA');
+ if (!isSha(after) || isZeroSha(after))
+ throw new Error('push event is missing an exact after SHA');
+ return { before, after };
+}
+
+export function pushCommitShas(payload, cwd = process.cwd(), rangeResolver = commitsInRange) {
+ const { before, after } = pushEventRange(payload);
+ return isZeroSha(before) ? [after] : rangeResolver(`${before}..${after}`, cwd);
+}
+
+// QNBS-v3: Git supplies both object IDs so malformed ref updates fail closed.
+export function parseRefUpdate(line) {
+ const fields = line.trim().split(/\s+/);
+ if (fields.length !== 4 || !fields[0] || !fields[1] || !fields[2] || !fields[3]) return null;
+ return { localRef: fields[0], localSha: fields[1], remoteRef: fields[2], remoteSha: fields[3] };
+}
+
+function refSha(ref, cwd) {
+ const sha = gitOutput(['rev-parse', '--verify', `${ref}^{commit}`], { cwd });
+ return isSha(sha) ? sha : null;
+}
+
+export function remoteTrackingBases(remote, remoteRef, cwd = process.cwd()) {
+ const bases = [];
+ const add = (ref) => {
+ const sha = refSha(ref, cwd);
+ if (sha && !bases.includes(sha)) bases.push(sha);
+ };
+ if (remoteRef.startsWith('refs/heads/')) {
+ const branch = remoteRef.slice('refs/heads/'.length);
+ add(`refs/remotes/${remote}/${branch}`);
+ const symbolic = gitOutput(
+ ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`],
+ { cwd },
+ );
+ if (symbolic) add(symbolic);
+ add(`refs/remotes/${remote}/main`);
+ add(`refs/remotes/${remote}/master`);
+ }
+ return bases;
+}
+
+export function outgoingBaseShas(update, fallbackBases) {
+ if (!isZeroSha(update.remoteSha)) {
+ if (!isSha(update.remoteSha)) throw new Error('invalid remote SHA in pre-push update');
+ return [update.remoteSha];
+ }
+ return fallbackBases;
+}
+
+export function introducedCommits(update, remote, cwd = process.cwd()) {
+ if (update.remoteRef.startsWith('refs/tags/')) return [];
+ if (!update.remoteRef.startsWith('refs/heads/')) {
+ throw new Error(`unsupported outgoing ref ${update.remoteRef}`);
+ }
+ const bases = outgoingBaseShas(update, remoteTrackingBases(remote, update.remoteRef, cwd));
+ const args = ['rev-list', '--reverse', update.localSha, ...bases.map((base) => `^${base}`)];
+ const result = runGit(args, { cwd });
+ if (result.status !== 0)
+ throw new Error(`cannot enumerate outgoing commits for ${update.localRef}`);
+ return result.stdout.split(/\r?\n/).filter(Boolean);
+}
+
+export function selectIntroducedCommits(commits, reachableFromBase) {
+ const excluded = new Set(reachableFromBase);
+ return commits.filter((sha) => !excluded.has(sha));
+}
+
+export function parseAnnotatedTag(sha, cwd = process.cwd()) {
+ const type = gitOutput(['cat-file', '-t', sha], { cwd });
+ if (type === 'commit') return { objectType: 'commit', target: sha };
+ if (type !== 'tag') return null;
+ const body = gitOutput(['cat-file', '-p', sha], { cwd });
+ const target = body.match(/^object ([0-9a-f]{40})$/m)?.[1] ?? '';
+ const targetType = target ? gitOutput(['cat-file', '-t', target], { cwd }) : '';
+ return { objectType: 'tag', target, targetType };
+}
+
+export function verifyTagObject(sha, cwd = process.cwd()) {
+ const tag = parseAnnotatedTag(sha, cwd);
+ if (!tag) return { ok: false, reason: 'tag target is not a commit or annotated tag' };
+ if (tag.objectType === 'commit')
+ return { ok: false, reason: 'release tag is not an annotated tag object' };
+ if (!tag.target || tag.targetType !== 'commit')
+ return { ok: false, reason: 'annotated tag does not target a commit' };
+ const verifiedTag = runGit(['verify-tag', '--raw', sha], { cwd });
+ if (verifiedTag.status !== 0)
+ return { ok: false, reason: 'Git-native tag signature verification failed' };
+ const verifiedCommit = verifyCommitObject(tag.target, cwd);
+ return verifiedCommit.ok
+ ? { ok: true, reason: 'tag and target commit verified' }
+ : verifiedCommit;
+}
+
+export function classifyTagVerification({
+ objectType,
+ targetType,
+ tagVerificationStatus,
+ commitVerification,
+}) {
+ if (objectType === 'commit')
+ return { ok: false, reason: 'release tag is not an annotated tag object' };
+ if (objectType !== 'tag' || targetType !== 'commit')
+ return { ok: false, reason: 'annotated tag does not target a commit' };
+ if (tagVerificationStatus !== 0)
+ return { ok: false, reason: 'Git-native tag signature verification failed' };
+ return commitVerification.ok
+ ? { ok: true, reason: 'tag and target commit verified' }
+ : commitVerification;
+}
+
+export function verifyOutgoingUpdates(lines, remote, cwd = process.cwd(), dependencies = {}) {
+ const verifyCommit = dependencies.verifyCommitObject ?? ((sha) => verifyCommitObject(sha, cwd));
+ const verifyTag = dependencies.verifyTagObject ?? ((sha) => verifyTagObject(sha, cwd));
+ const getIntroducedCommits =
+ dependencies.introducedCommits ?? ((update) => introducedCommits(update, remote, cwd));
+ const updates = lines.map(parseRefUpdate);
+ if (updates.some((update) => !update))
+ return { ok: false, reason: 'invalid pre-push ref-update input' };
+ const reports = [];
+ for (const update of updates) {
+ if (isZeroSha(update.localSha)) continue;
+ if (!isSha(update.localSha))
+ return { ok: false, reason: `invalid outgoing SHA for ${update.remoteRef}` };
+ if (update.remoteRef.startsWith('refs/tags/')) {
+ const verification = verifyTag(update.localSha);
+ reports.push({ sha: update.localSha, subject: update.remoteRef, verification });
+ if (!verification.ok)
+ return { ok: false, reports, reason: `${update.remoteRef}: ${verification.reason}` };
+ continue;
+ }
+ const commits = getIntroducedCommits(update);
+ for (const sha of commits) {
+ const verification = verifyCommit(sha);
+ const report = { sha, subject: commitSubject(sha, cwd), verification };
+ reports.push(report);
+ if (!verification.ok)
+ return { ok: false, reports, reason: `${sha.slice(0, 12)}: ${verification.reason}` };
+ }
+ }
+ return { ok: true, reports };
+}
+
+export function safeConfigSummary(cwd = process.cwd()) {
+ const signing = getSigningConfig(cwd);
+ const identity = getIdentity(cwd);
+ const gitDir = getGitDirectory(cwd);
+ const hookDir = signing.config['core.hooksPath']
+ ? resolve(cwd, signing.config['core.hooksPath'])
+ : gitDir
+ ? join(gitDir, 'hooks')
+ : null;
+ const hookNames = ['pre-commit', 'pre-push'];
+ return {
+ signing: {
+ format: signing.format,
+ enabled: signing.enabled,
+ keyConfigured: signing.keyConfigured,
+ keyDisplay: signing.keyDisplay,
+ gpgProgramConfigured: signing.gpgProgramConfigured,
+ allowedSignersConfigured: signing.allowedSignersConfigured,
+ },
+ identity: {
+ nameConfigured: Boolean(identity.name),
+ emailConfigured: Boolean(identity.email),
+ githubCompatible: isGitHubCompatibleEmail(identity.email),
+ },
+ hooks: {
+ pathConfigured: Boolean(signing.config['core.hooksPath']),
+ directory: hookDir ? basename(hookDir) : '',
+ hooksInstalled: Boolean(
+ hookDir && hookNames.every((name) => existsSync(join(hookDir, name))),
+ ),
+ },
+ unsafeOverrides: getUnsafeOverrides(),
+ };
+}
diff --git a/scripts/signing/verify-github-signatures.mjs b/scripts/signing/verify-github-signatures.mjs
new file mode 100644
index 000000000..019bfdf52
--- /dev/null
+++ b/scripts/signing/verify-github-signatures.mjs
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+import { readFileSync } from 'node:fs';
+import process from 'node:process';
+import { commitsInRange, pushCommitShas } from './signing-core.mjs';
+import {
+ hasCompleteCommitRange,
+ verifyRemoteCommitRange,
+ verifyRemotePullRequest,
+ verifyRemoteTag,
+} from './verify-remote.mjs';
+
+const [owner, repo] = String(process.env.GITHUB_REPOSITORY ?? '').split('/', 2);
+const token = process.env.GITHUB_TOKEN ?? '';
+const fetchImpl = fetch;
+const event = process.env.GITHUB_EVENT_NAME ?? '';
+
+function fail(message) {
+ console.error(`signature gate failed: ${message}`);
+ process.exit(1);
+}
+
+function readEventPayload() {
+ const eventPath = process.env.GITHUB_EVENT_PATH;
+ if (!eventPath) fail('GITHUB_EVENT_PATH is unavailable');
+ try {
+ return JSON.parse(readFileSync(eventPath, 'utf8'));
+ } catch (error) {
+ fail(
+ `cannot read GitHub event payload: ${error instanceof Error ? error.message : 'invalid JSON'}`,
+ );
+ }
+}
+
+if (!owner || !repo) fail('GITHUB_REPOSITORY is unavailable');
+
+try {
+ let result;
+ if (event === 'pull_request') {
+ const payload = readEventPayload();
+ const number = payload.pull_request?.number;
+ const before = payload.pull_request?.base?.sha;
+ const after = payload.pull_request?.head?.sha;
+ if (!number || !before || !after)
+ fail('pull request payload is missing its exact before/after SHAs');
+ const expected = new Set(commitsInRange(`${before}..${after}`));
+ result = await verifyRemotePullRequest({ owner, repo, number, token, fetchImpl });
+ if (!hasCompleteCommitRange([...expected], result.reports)) {
+ fail('GitHub PR pagination did not cover the complete before..after commit range');
+ }
+ } else if (event === 'push' && process.env.GITHUB_REF_TYPE === 'tag') {
+ result = await verifyRemoteTag({
+ owner,
+ repo,
+ tag: process.env.GITHUB_REF_NAME,
+ token,
+ fetchImpl,
+ });
+ } else {
+ const shas = pushCommitShas(readEventPayload());
+ result = await verifyRemoteCommitRange({ owner, repo, shas, token, fetchImpl });
+ }
+ for (const report of result.reports) {
+ console.log(
+ `${report.sha} ${report.verified ? 'verified' : 'REJECTED'} ${report.reason} ${report.subject}`,
+ );
+ }
+ if (!result.ok) fail(result.reason);
+} catch (error) {
+ fail(error instanceof Error ? error.message : 'unexpected verification error');
+}
diff --git a/scripts/signing/verify-outgoing.mjs b/scripts/signing/verify-outgoing.mjs
new file mode 100644
index 000000000..a499b9eca
--- /dev/null
+++ b/scripts/signing/verify-outgoing.mjs
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+import process from 'node:process';
+import { verifyOutgoingUpdates } from './signing-core.mjs';
+
+const remote = process.argv[2];
+if (!remote) {
+ console.error(
+ 'pre-push signing check requires the remote name. Run "pnpm run hooks:install" to refresh the installed hook.',
+ );
+ process.exit(1);
+}
+let input = '';
+process.stdin.setEncoding('utf8');
+for await (const chunk of process.stdin) input += chunk;
+const lines = input.split(/\r?\n/).filter(Boolean);
+try {
+ const result = verifyOutgoingUpdates(lines, remote);
+ if (!result.ok) {
+ console.error(`pre-push signing check rejected the update: ${result.reason}`);
+ process.exit(1);
+ }
+ console.log(`pre-push signing check verified ${result.reports.length} outgoing object(s)`);
+} catch (error) {
+ console.error(
+ `pre-push signing check failed closed: ${error instanceof Error ? error.message : 'unknown error'}`,
+ );
+ process.exit(1);
+}
diff --git a/scripts/signing/verify-remote.d.mts b/scripts/signing/verify-remote.d.mts
new file mode 100644
index 000000000..9e7b00fbe
--- /dev/null
+++ b/scripts/signing/verify-remote.d.mts
@@ -0,0 +1,55 @@
+export interface RemoteCommit {
+ sha: string;
+ commit?: {
+ message?: string;
+ verification?: {
+ verified?: boolean;
+ reason?: string;
+ };
+ };
+}
+
+export interface RemoteReport {
+ sha: string;
+ subject: string;
+ verified: boolean;
+ reason: string;
+}
+
+export interface RemoteResult {
+ ok: boolean;
+ reports: RemoteReport[];
+ reason: string;
+}
+
+export interface RemoteOptions {
+ owner: string;
+ repo: string;
+ number: number | string;
+ token?: string;
+ fetchImpl?: typeof fetch;
+}
+
+export interface RemoteCommitRangeOptions {
+ owner: string;
+ repo: string;
+ shas: string[];
+ token?: string;
+ fetchImpl?: typeof fetch;
+}
+
+export function fetchJson(fetchImpl: typeof fetch, url: string, token: string): Promise;
+export function fetchPullRequestCommits(options: RemoteOptions): Promise;
+export function safeReport(commit: RemoteCommit): RemoteReport;
+
+export function verifyRemoteCommitReports(commits: RemoteCommit[]): RemoteResult;
+export function hasCompleteCommitRange(expectedShas: string[], reports: RemoteReport[]): boolean;
+export function verifyRemotePullRequest(options: RemoteOptions): Promise;
+export function verifyRemoteCommitRange(options: RemoteCommitRangeOptions): Promise;
+export function verifyRemoteTag(options: {
+ owner: string;
+ repo: string;
+ tag: string;
+ token?: string;
+ fetchImpl?: typeof fetch;
+}): Promise;
diff --git a/scripts/signing/verify-remote.mjs b/scripts/signing/verify-remote.mjs
new file mode 100644
index 000000000..236620a78
--- /dev/null
+++ b/scripts/signing/verify-remote.mjs
@@ -0,0 +1,176 @@
+#!/usr/bin/env node
+import process from 'node:process';
+
+const API = 'https://api.github.com';
+
+function safeText(value, limit = 200) {
+ return Array.from(String(value ?? ''), (character) =>
+ character.charCodeAt(0) >= 32 && character.charCodeAt(0) !== 127 ? character : ' ',
+ )
+ .join('')
+ .slice(0, limit);
+}
+
+function safeSha(value) {
+ return String(value ?? '').match(/^[0-9a-f]{12}/i)?.[0] ?? 'invalid-sha';
+}
+
+function safeSubject(message) {
+ return (
+ safeText(
+ String(message ?? '')
+ .split(/\r?\n/, 1)[0]
+ .slice(0, 160),
+ ) || '(subject unavailable)'
+ );
+}
+
+function safeReport(commit) {
+ return {
+ sha: safeSha(commit.sha),
+ subject: safeSubject(commit.commit?.message),
+ verified: commit.commit?.verification?.verified === true,
+ reason: safeText(commit.commit?.verification?.reason ?? 'missing verification result'),
+ };
+}
+
+export async function fetchJson(fetchImpl, url, token) {
+ const response = await fetchImpl(url, {
+ headers: {
+ Accept: 'application/vnd.github+json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ 'X-GitHub-Api-Version': '2022-11-28',
+ },
+ });
+ if (!response.ok) throw new Error(`GitHub API request failed (${response.status})`);
+ return response.json();
+}
+
+export async function fetchPullRequestCommits({
+ owner,
+ repo,
+ number,
+ token = '',
+ fetchImpl = fetch,
+}) {
+ const commits = [];
+ for (let page = 1; ; page += 1) {
+ const batch = await fetchJson(
+ fetchImpl,
+ `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}/commits?per_page=100&page=${page}`,
+ token,
+ );
+ if (!Array.isArray(batch)) throw new Error('GitHub PR commits response was not an array');
+ commits.push(...batch);
+ if (batch.length < 100) return commits;
+ }
+}
+
+export function verifyRemoteCommitReports(commits) {
+ const reports = commits.map(safeReport);
+ const invalid = reports.find((report) => !report.verified);
+ return { ok: !invalid, reports, reason: invalid ? `${invalid.sha}: ${invalid.reason}` : '' };
+}
+
+export function hasCompleteCommitRange(expectedShas, reports) {
+ if (expectedShas.length !== reports.length) return false;
+ return expectedShas.every((sha) => reports.some((report) => sha.startsWith(report.sha)));
+}
+
+export async function verifyRemotePullRequest(options) {
+ const commits = await fetchPullRequestCommits(options);
+ return verifyRemoteCommitReports(commits);
+}
+
+export async function verifyRemoteCommitRange({
+ owner,
+ repo,
+ shas,
+ token = '',
+ fetchImpl = fetch,
+}) {
+ const commits = [];
+ for (const sha of shas) {
+ commits.push(
+ await fetchJson(
+ fetchImpl,
+ `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${sha}`,
+ token,
+ ),
+ );
+ }
+ return verifyRemoteCommitReports(commits);
+}
+
+export async function verifyRemoteTag({ owner, repo, tag, token = '', fetchImpl = fetch }) {
+ const ref = await fetchJson(
+ fetchImpl,
+ `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/ref/tags/${encodeURIComponent(tag)}`,
+ token,
+ );
+ const object = ref.object;
+ if (!object?.sha || !object.type) throw new Error('GitHub tag ref response was incomplete');
+ if (object.type !== 'tag') {
+ const report = {
+ sha: safeSha(object.sha),
+ subject: safeText(`tag ${tag}`, 160),
+ verified: false,
+ reason: 'release tag is not an annotated tag object',
+ };
+ return { ok: false, reports: [report], reason: `${report.sha}: ${report.reason}` };
+ }
+ let tagReport = null;
+ let targetSha = object.sha;
+ const tagObject = await fetchJson(
+ fetchImpl,
+ `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/tags/${encodeURIComponent(object.sha)}`,
+ token,
+ );
+ tagReport = {
+ sha: safeSha(object.sha),
+ subject: safeText(`tag ${tag}`, 160),
+ verified: tagObject.verification?.verified === true,
+ reason: safeText(tagObject.verification?.reason ?? 'missing tag verification result'),
+ };
+ targetSha = tagObject.object?.sha;
+ if (tagObject.object?.type !== 'commit' || !targetSha)
+ throw new Error('annotated tag does not target a commit');
+ const commit = await fetchJson(
+ fetchImpl,
+ `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(targetSha)}`,
+ token,
+ );
+ const commitReport = safeReport(commit);
+ const reports = tagReport ? [tagReport, commitReport] : [commitReport];
+ const invalid = reports.find((report) => !report.verified);
+ return { ok: !invalid, reports, reason: invalid ? `${invalid.sha}: ${invalid.reason}` : '' };
+}
+
+export { safeReport };
+
+if (process.argv[1]?.endsWith('/verify-remote.mjs')) {
+ const [repository, number] = process.argv.slice(2);
+ const [owner, repo] = String(repository ?? process.env.GITHUB_REPOSITORY ?? '').split('/', 2);
+ if (!owner || !repo || !number) {
+ console.error('usage: pnpm run signing:verify-remote -- / ');
+ process.exit(2);
+ }
+ try {
+ const result = await verifyRemotePullRequest({
+ owner,
+ repo,
+ number,
+ token: process.env.GITHUB_TOKEN ?? '',
+ });
+ for (const report of result.reports)
+ console.log(
+ `${report.sha} ${report.verified ? 'verified' : 'REJECTED'} ${report.reason} ${report.subject}`,
+ );
+ process.exit(result.ok ? 0 : 1);
+ } catch (error) {
+ console.error(
+ `remote signature verification failed: ${error instanceof Error ? error.message : 'unexpected API error'}`,
+ );
+ process.exit(1);
+ }
+}
diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts
new file mode 100644
index 000000000..c14f30c7c
--- /dev/null
+++ b/tests/unit/signing.test.ts
@@ -0,0 +1,291 @@
+import { describe, expect, it } from 'vitest';
+import {
+ classifyCommitObject,
+ classifyTagVerification,
+ getSigningConfig,
+ hasCommitSignature,
+ isGitHubCompatibleEmail,
+ outgoingBaseShas,
+ parseRefUpdate,
+ pushCommitShas,
+ pushEventRange,
+ selectIntroducedCommits,
+ verifyOutgoingUpdates,
+} from '../../scripts/signing/signing-core.mjs';
+import {
+ hasCompleteCommitRange,
+ verifyRemoteCommitReports,
+ verifyRemotePullRequest,
+ verifyRemoteTag,
+} from '../../scripts/signing/verify-remote.mjs';
+
+describe('local signing controls', () => {
+ it('parses every pre-push update shape, including deletion', () => {
+ const remoteSha = 'c'.repeat(40);
+ expect(
+ parseRefUpdate(`refs/heads/main ${'a'.repeat(40)} refs/heads/main ${remoteSha}`),
+ ).toEqual({
+ localRef: 'refs/heads/main',
+ localSha: 'a'.repeat(40),
+ remoteRef: 'refs/heads/main',
+ remoteSha,
+ });
+ expect(
+ parseRefUpdate(`refs/heads/deleted ${'0'.repeat(40)} refs/heads/deleted ${'d'.repeat(40)}`),
+ ).toEqual({
+ localRef: 'refs/heads/deleted',
+ localSha: '0'.repeat(40),
+ remoteRef: 'refs/heads/deleted',
+ remoteSha: 'd'.repeat(40),
+ });
+ expect(parseRefUpdate('refs/heads/main abc refs/heads/main')).toBeNull();
+ });
+
+ it('checks only commits introduced beyond the remote tracking base', () => {
+ const base = 'a'.repeat(40);
+ const introduced = 'b'.repeat(40);
+ expect(selectIntroducedCommits([base, introduced], [base])).toEqual([introduced]);
+ });
+
+ it('uses the Git-advertised remote SHA before new-branch fallbacks', () => {
+ const remoteSha = 'c'.repeat(40);
+ const fallback = ['d'.repeat(40)];
+ expect(
+ outgoingBaseShas(
+ {
+ localRef: 'refs/heads/main',
+ localSha: 'a'.repeat(40),
+ remoteRef: 'refs/heads/main',
+ remoteSha,
+ },
+ fallback,
+ ),
+ ).toEqual([remoteSha]);
+ expect(
+ outgoingBaseShas(
+ {
+ localRef: 'refs/heads/new',
+ localSha: 'a'.repeat(40),
+ remoteRef: 'refs/heads/new',
+ remoteSha: '0'.repeat(40),
+ },
+ fallback,
+ ),
+ ).toEqual(fallback);
+ });
+
+ it('covers deletion, malformed, tag, and branch pre-push routing', () => {
+ const zero = '0'.repeat(40);
+ const commit = 'a'.repeat(40);
+ const remote = 'b'.repeat(40);
+ expect(
+ verifyOutgoingUpdates(
+ [`refs/heads/deleted ${zero} refs/heads/deleted ${remote}`],
+ 'origin',
+ process.cwd(),
+ {
+ introducedCommits: () => {
+ throw new Error('deletions must be skipped');
+ },
+ },
+ ),
+ ).toMatchObject({ ok: true, reports: [] });
+ expect(verifyOutgoingUpdates(['malformed'], 'origin')).toMatchObject({
+ ok: false,
+ reason: 'invalid pre-push ref-update input',
+ });
+ expect(
+ verifyOutgoingUpdates(
+ [`refs/tags/v1.0.0 ${commit} refs/tags/v1.0.0 ${zero}`],
+ 'origin',
+ process.cwd(),
+ {
+ verifyTagObject: () => ({ ok: true, reason: 'tag and target commit verified' }),
+ introducedCommits: () => {
+ throw new Error('tags must not enumerate branch commits');
+ },
+ },
+ ),
+ ).toMatchObject({ ok: true, reports: [{ sha: commit, subject: 'refs/tags/v1.0.0' }] });
+ expect(
+ verifyOutgoingUpdates(
+ [`refs/heads/main ${commit} refs/heads/main ${remote}`],
+ 'origin',
+ process.cwd(),
+ {
+ introducedCommits: (update) => {
+ expect(update.remoteSha).toBe(remote);
+ return [commit];
+ },
+ verifyCommitObject: () => ({ ok: true, reason: 'Git-native signature verified' }),
+ },
+ ),
+ ).toMatchObject({ ok: true, reports: [{ sha: commit }] });
+ });
+
+ it('derives an exact push range and rejects an unverified earlier commit', () => {
+ const before = '0'.repeat(40);
+ const after = 'f'.repeat(40);
+ expect(pushEventRange({ before, after })).toEqual({ before, after });
+ expect(
+ pushCommitShas({ before: '1'.repeat(40), after }, process.cwd(), () => [
+ '1'.repeat(40),
+ 'e'.repeat(40),
+ after,
+ ]),
+ ).toEqual(['1'.repeat(40), 'e'.repeat(40), after]);
+ const result = verifyRemoteCommitReports([
+ {
+ sha: '1'.repeat(40),
+ commit: { message: 'earlier', verification: { verified: false, reason: 'unsigned' } },
+ },
+ {
+ sha: after,
+ commit: { message: 'head', verification: { verified: true, reason: 'valid' } },
+ },
+ ]);
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('111111111111: unsigned');
+ });
+
+ it('requires both an annotated tag signature and its target commit signature', () => {
+ const unsignedCommit = { ok: false, reason: 'commit has no signature object' };
+ expect(
+ classifyTagVerification({
+ objectType: 'tag',
+ targetType: 'commit',
+ tagVerificationStatus: 0,
+ commitVerification: unsignedCommit,
+ }),
+ ).toEqual(unsignedCommit);
+ expect(
+ classifyTagVerification({
+ objectType: 'tag',
+ targetType: 'commit',
+ tagVerificationStatus: 1,
+ commitVerification: { ok: true, reason: 'verified' },
+ }),
+ ).toMatchObject({ ok: false });
+ expect(
+ classifyTagVerification({
+ objectType: 'commit',
+ targetType: 'commit',
+ tagVerificationStatus: 0,
+ commitVerification: { ok: true, reason: 'verified' },
+ }),
+ ).toMatchObject({ ok: false, reason: 'release tag is not an annotated tag object' });
+ });
+
+ it('distinguishes unsigned and malformed signed commit objects', () => {
+ expect(
+ classifyCommitObject({ objectType: 'commit', contents: '', verificationStatus: 0 }),
+ ).toMatchObject({ ok: false, reason: 'commit has no signature object' });
+ expect(hasCommitSignature('\ngpgsig -----BEGIN SSH SIGNATURE-----\n')).toBe(true);
+ expect(
+ classifyCommitObject({
+ objectType: 'commit',
+ contents: '\ngpgsig malformed\n',
+ verificationStatus: 1,
+ }),
+ ).toMatchObject({ ok: false, reason: 'Git-native signature verification failed' });
+ });
+
+ it('reports configuration and GitHub-compatible identity without a key fallback', () => {
+ expect(getSigningConfig()).toHaveProperty('enabled');
+ expect(isGitHubCompatibleEmail('155236708+qnbs@users.noreply.github.com')).toBe(true);
+ expect(isGitHubCompatibleEmail('writer@example.com')).toBe(false);
+ });
+});
+
+describe('GitHub signature API controls', () => {
+ it('rejects any unverified commit while preserving safe report fields', () => {
+ const result = verifyRemoteCommitReports([
+ {
+ sha: 'a'.repeat(40),
+ commit: { message: 'signed', verification: { verified: true, reason: 'valid' } },
+ },
+ {
+ sha: 'b'.repeat(40),
+ commit: {
+ message: 'unsigned\nbody',
+ verification: { verified: false, reason: 'unsigned' },
+ },
+ },
+ ]);
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBe('bbbbbbbbbbbb: unsigned');
+ expect(result.reports[1]).toEqual({
+ sha: 'bbbbbbbbbbbb',
+ subject: 'unsigned',
+ verified: false,
+ reason: 'unsigned',
+ });
+ expect(hasCompleteCommitRange(['a'.repeat(40), 'b'.repeat(40)], result.reports)).toBe(true);
+ expect(hasCompleteCommitRange(['a'.repeat(40), 'c'.repeat(40)], result.reports)).toBe(false);
+ });
+
+ it('paginates PR commits and verifies annotated tags plus target commits', async () => {
+ const verifiedCommit = (sha: string) => ({
+ sha,
+ commit: { message: 'commit', verification: { verified: true, reason: 'valid' } },
+ });
+ const pages = new Map([
+ [
+ 'page=1',
+ Array.from({ length: 100 }, (_, index) => verifiedCommit(String(index).padStart(40, '0'))),
+ ],
+ ['page=2', [verifiedCommit('f'.repeat(40))]],
+ ]);
+ const fetchImpl: typeof fetch = async (input) =>
+ new Response(JSON.stringify(pages.get(String(input).split('&').pop() ?? '') ?? []), {
+ status: 200,
+ });
+ const pullRequest = await verifyRemotePullRequest({
+ owner: 'qnbs',
+ repo: 'WorldScript-Studio',
+ number: 1,
+ fetchImpl,
+ });
+ expect(pullRequest.ok).toBe(true);
+ expect(pullRequest.reports).toHaveLength(101);
+
+ const tagObjectSha = '1'.repeat(40);
+ const targetSha = '2'.repeat(40);
+ const tagFetch: typeof fetch = async (input) => {
+ const url = String(input);
+ if (url.includes('/git/ref/tags/'))
+ return new Response(JSON.stringify({ object: { sha: tagObjectSha, type: 'tag' } }), {
+ status: 200,
+ });
+ if (url.includes('/git/tags/'))
+ return new Response(
+ JSON.stringify({
+ object: { sha: targetSha, type: 'commit' },
+ verification: { verified: true, reason: 'valid' },
+ }),
+ { status: 200 },
+ );
+ return new Response(JSON.stringify(verifiedCommit(targetSha)), { status: 200 });
+ };
+ const tag = await verifyRemoteTag({
+ owner: 'qnbs',
+ repo: 'WorldScript-Studio',
+ tag: 'v1.0.0',
+ fetchImpl: tagFetch,
+ });
+ expect(tag.ok).toBe(true);
+ expect(tag.reports).toHaveLength(2);
+
+ const lightweight = await verifyRemoteTag({
+ owner: 'qnbs',
+ repo: 'WorldScript-Studio',
+ tag: 'v1.0.0-lightweight',
+ fetchImpl: async () =>
+ new Response(JSON.stringify({ object: { sha: targetSha, type: 'commit' } }), {
+ status: 200,
+ }),
+ });
+ expect(lightweight.ok).toBe(false);
+ expect(lightweight.reason).toContain('not an annotated tag object');
+ });
+});
diff --git a/tests/unit/workflowPolicy.test.ts b/tests/unit/workflowPolicy.test.ts
index 01bdb2010..4c7d9298b 100644
--- a/tests/unit/workflowPolicy.test.ts
+++ b/tests/unit/workflowPolicy.test.ts
@@ -131,6 +131,7 @@ describe('CI workflow policy', () => {
);
expect(extractNeeds(workflowSource, 'ci-success')).toEqual([
'security',
+ 'signatures',
'quality',
'changes',
'rust-tauri',
@@ -140,6 +141,7 @@ describe('CI workflow policy', () => {
'lighthouse',
'vrt',
]);
+ expect(ciSuccessBlock).toMatch(/\$\{\{\s*needs\.signatures\.result\s*\}\}/);
expect(ciSuccessBlock).toMatch(/\$\{\{\s*needs\.lighthouse\.result\s*\}\}/);
for (const jobName of ['e2e-deep', 'storybook']) {