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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- **Release-tag verification:** Validate protected-main ancestry with the GitHub Compare API's returned base and merge-base commits, and bind the verified tag target to the triggering event SHA (PR #1207).

<!-- release-candidate: v1.29.3 -->

## [1.29.3] — 2026-11-08
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<img src="https://img.shields.io/badge/TypeScript-tsgo_native_preview-3178C6?logo=typescript&logoColor=white" alt="TypeScript native preview (tsgo)">
<img src="https://img.shields.io/badge/Desktop-Tauri_2-FFC131?logo=tauri&logoColor=black" alt="Tauri 2">
<img src="https://img.shields.io/badge/i18n-19_locales_%2F_2963_keys-0EA5E9" alt="19 locales — 2963 keys">
<img src="https://img.shields.io/badge/Tests-8698%2B_%2F_655_files-22C55E" alt="8698+ tests / 655 files">
<img src="https://img.shields.io/badge/Tests-8705%2B_%2F_656_files-22C55E" alt="8705+ tests / 656 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/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github&label=CI" alt="CI status">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="MIT License">
Expand Down Expand Up @@ -807,7 +807,7 @@ The normal web PR pipeline is not the complete native release qualification surf
| DOCX | `docx` + JSZip | Word-compatible export |
| PWA | Service Worker + Web App Manifest | Offline shell/installability |
| i18n | Custom React i18n context | 2963 keys × 19 locales |
| Testing | Vitest 5.x (8698+ tests / 655 files) + Playwright | Unit/integration/E2E |
| Testing | Vitest 5.x (8705+ tests / 656 files) + Playwright | Unit/integration/E2E |
| Quality | Biome + tsgo + CodeQL/security tooling | Static and CI gates |
| Desktop | Tauri 2 | Current native shell |

Expand Down Expand Up @@ -884,7 +884,7 @@ WorldScript-Studio/
├── locales/ # Source locale trees
├── public/ # PWA assets, manifest, SW, runtime locale bundles
├── tests/
│ ├── unit/ # Vitest unit tests (the 8698+ total also includes components/ and packages/*/tests; tests/e2e is excluded)
│ ├── unit/ # Vitest unit tests (the 8705+ total also includes components/ and packages/*/tests; tests/e2e is excluded)
│ └── e2e/ # Playwright
├── docs/ # Canonical product/engineering documentation + ADRs
├── src-tauri/ # Tauri v2 desktop shell / Rust
Expand Down Expand Up @@ -1029,7 +1029,7 @@ Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendo

Current source-synchronized README metrics:

- **8698+ unit tests** across **655 test files**
- **8705+ unit tests** across **656 test files**
- i18n: **2963 keys × 19 locales**

CI remains authoritative for actual pass/fail and live coverage.
Expand Down
9 changes: 8 additions & 1 deletion scripts/signing/verify-github-signatures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
import process from 'node:process';
import { commitsInRange, pushCommitShas } from './signing-core.mjs';
import {
expectedTagCommitSha,
hasCompleteCommitRange,
verifyRemoteCommitRange,
verifyRemotePullRequest,
Expand Down Expand Up @@ -48,10 +49,16 @@ try {
fail('GitHub PR pagination did not cover the complete before..after commit range');
}
} else if (event === 'push' && process.env.GITHUB_REF_TYPE === 'tag') {
const payload = readEventPayload();
const tagName = process.env.GITHUB_REF_NAME ?? '';
if (payload.ref !== `refs/tags/${tagName}` || !tagName.startsWith('v'))
fail('push event ref does not match the requested version tag');
const expectedCommitSha = expectedTagCommitSha(payload.after, process.env.GITHUB_SHA);
result = await verifyRemoteTag({
owner,
repo,
tag: process.env.GITHUB_REF_NAME,
tag: tagName,
expectedCommitSha,
token,
fetchImpl,
});
Expand Down
8 changes: 7 additions & 1 deletion scripts/signing/verify-remote.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export interface RemoteResult {
reason: string;
}

export interface VerifiedTagResult extends RemoteResult {
verifiedTagSha?: string;
}

export interface RemoteOptions {
owner: string;
repo: string;
Expand All @@ -44,12 +48,14 @@ export function safeReport(commit: RemoteCommit): RemoteReport;

export function verifyRemoteCommitReports(commits: RemoteCommit[]): RemoteResult;
export function hasCompleteCommitRange(expectedShas: string[], reports: RemoteReport[]): boolean;
export function expectedTagCommitSha(eventAfter: string, githubSha: string): string;
export function verifyRemotePullRequest(options: RemoteOptions): Promise<RemoteResult>;
export function verifyRemoteCommitRange(options: RemoteCommitRangeOptions): Promise<RemoteResult>;
export function verifyRemoteTag(options: {
owner: string;
repo: string;
tag: string;
expectedCommitSha: string;
token?: string;
fetchImpl?: typeof fetch;
}): Promise<RemoteResult>;
}): Promise<VerifiedTagResult>;
93 changes: 86 additions & 7 deletions scripts/signing/verify-remote.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,39 @@ function safeReport(commit) {
};
}

// QNBS-v3: require a stable protected-main snapshot across the ancestry API request.
async function verifyProtectedMainAncestry({ owner, repo, sha, token, fetchImpl }) {
const branchUrl = `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches/main`;
const branch = await fetchJson(fetchImpl, branchUrl, token);
const mainSha = String(branch.commit?.sha ?? '').toLowerCase();
if (branch.name !== 'main' || branch.protected !== true || !/^[0-9a-f]{40}$/.test(mainSha)) {
throw new Error('protected main branch identity could not be verified');
}

const comparison = await fetchJson(
fetchImpl,
`${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/compare/${encodeURIComponent(`${sha}...${mainSha}`)}`,
token,
);
if (
!['ahead', 'identical'].includes(comparison.status) ||
comparison.base_commit?.sha?.toLowerCase() !== sha ||
comparison.merge_base_commit?.sha?.toLowerCase() !== sha
) {
throw new Error('release tag target is not contained in protected main');
}
Comment thread
codeant-ai[bot] marked this conversation as resolved.
Resolved

const currentBranch = await fetchJson(fetchImpl, branchUrl, token);
const currentMainSha = String(currentBranch.commit?.sha ?? '').toLowerCase();
if (
currentBranch.name !== 'main' ||
currentBranch.protected !== true ||
currentMainSha !== mainSha
) {
throw new Error('protected main changed during ancestry verification');
}
}

export async function fetchJson(fetchImpl, url, token) {
const response = await fetchImpl(url, {
headers: {
Expand Down Expand Up @@ -77,6 +110,21 @@ export function hasCompleteCommitRange(expectedShas, reports) {
return expectedShas.every((sha) => reports.some((report) => sha.startsWith(report.sha)));
}

export function expectedTagCommitSha(eventAfter, githubSha) {
const eventSha = String(eventAfter ?? '');
const contextSha = String(githubSha ?? '');
if (!/^[0-9a-f]{40}$/i.test(eventSha))
throw new Error('push event does not contain a full tag target commit SHA');
if (/^0{40}$/i.test(eventSha))
throw new Error('push event does not contain a live tag target commit SHA');
if (!/^[0-9a-f]{40}$/i.test(contextSha))
throw new Error('GitHub context does not contain a full commit SHA');
if (/^0{40}$/i.test(contextSha)) throw new Error('GitHub context contains a deleted-ref SHA');
if (eventSha.toLowerCase() !== contextSha.toLowerCase())
throw new Error('push event target does not match github.sha');
return eventSha.toLowerCase();
}

export async function verifyRemotePullRequest(options) {
const commits = await fetchPullRequestCommits(options);
return verifyRemoteCommitReports(commits);
Expand All @@ -102,12 +150,21 @@ export async function verifyRemoteCommitRange({
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,
);
export async function verifyRemoteTag({
owner,
repo,
tag,
expectedCommitSha,
token = '',
fetchImpl = fetch,
}) {
if (!/^[0-9a-f]{40}$/i.test(String(expectedCommitSha ?? '')))
throw new Error('expected tag target must be a full commit SHA');
if (/^0{40}$/i.test(expectedCommitSha))
throw new Error('expected tag target cannot be the deleted-ref SHA');
const expectedSha = expectedCommitSha.toLowerCase();
const refUrl = `${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/ref/tags/${encodeURIComponent(tag)}`;
const ref = await fetchJson(fetchImpl, refUrl, token);
const object = ref.object;
if (!object?.sha || !object.type) throw new Error('GitHub tag ref response was incomplete');
if (object.type !== 'tag') {
Expand All @@ -126,6 +183,7 @@ export async function verifyRemoteTag({ owner, repo, tag, token = '', fetchImpl
`${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/tags/${encodeURIComponent(object.sha)}`,
token,
);
if (tagObject.tag !== tag) throw new Error('annotated tag name does not match the pushed ref');
tagReport = {
sha: safeSha(object.sha),
subject: safeText(`tag ${tag}`, 160),
Expand All @@ -135,6 +193,21 @@ export async function verifyRemoteTag({ owner, repo, tag, token = '', fetchImpl
targetSha = tagObject.object?.sha;
if (tagObject.object?.type !== 'commit' || !targetSha)
throw new Error('annotated tag does not target a commit');
if (targetSha.toLowerCase() !== expectedSha) {
return {
ok: false,
reports: [
tagReport,
{
sha: safeSha(targetSha),
subject: safeText(`tag ${tag}`, 160),
verified: false,
reason: 'tag target does not match the pushed event commit',
},
],
reason: `${safeSha(targetSha)}: tag target does not match the pushed event commit`,
};
}
const commit = await fetchJson(
fetchImpl,
`${API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/commits/${encodeURIComponent(targetSha)}`,
Expand All @@ -143,7 +216,13 @@ export async function verifyRemoteTag({ owner, repo, tag, token = '', fetchImpl
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}` : '' };
if (invalid) return { ok: false, reports, reason: `${invalid.sha}: ${invalid.reason}` };
await verifyProtectedMainAncestry({ owner, repo, sha: expectedSha, token, fetchImpl });
const currentRef = await fetchJson(fetchImpl, refUrl, token);
if (currentRef.object?.type !== 'tag' || currentRef.object?.sha !== object.sha) {
throw new Error('release tag changed during signature verification');
}
return { ok: true, reports, reason: '', verifiedTagSha: expectedSha };
}

export { safeReport };
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/signing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,26 +874,44 @@ describe('GitHub signature API controls', () => {
if (url.includes('/git/tags/'))
return new Response(
JSON.stringify({
tag: 'v1.0.0',
object: { sha: targetSha, type: 'commit' },
verification: { verified: true, reason: 'valid' },
}),
{ status: 200 },
);
if (url.includes('/branches/main'))
return new Response(
JSON.stringify({ name: 'main', protected: true, commit: { sha: targetSha } }),
{ status: 200 },
);
if (url.includes('/compare/'))
return new Response(
JSON.stringify({
status: 'identical',
base_commit: { sha: targetSha },
merge_base_commit: { sha: targetSha },
}),
{ status: 200 },
);
return new Response(JSON.stringify(verifiedCommit(targetSha)), { status: 200 });
};
const tag = await verifyRemoteTag({
owner: 'qnbs',
repo: 'WorldScript-Studio',
tag: 'v1.0.0',
expectedCommitSha: targetSha,
fetchImpl: tagFetch,
});
expect(tag.ok).toBe(true);
expect(tag.verifiedTagSha).toBe(targetSha);
expect(tag.reports).toHaveLength(2);

const lightweight = await verifyRemoteTag({
owner: 'qnbs',
repo: 'WorldScript-Studio',
tag: 'v1.0.0-lightweight',
expectedCommitSha: targetSha,
fetchImpl: async () =>
new Response(JSON.stringify({ object: { sha: targetSha, type: 'commit' } }), {
status: 200,
Expand Down
Loading
Loading