diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37b1203d7..3dab8eded 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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).
+
## [1.29.3] — 2026-11-08
diff --git a/README.md b/README.md
index cb764736e..ec6d206b5 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
-
+
@@ -807,7 +807,7 @@ The normal web PR pipeline is not the complete native release qualification surf
| DOCX | `docx` + JSZip | Word-compatible export |
| PWA | Service Worker + Web App Manifest | Offline shell/installability |
| i18n | Custom React i18n context | 2963 keys × 19 locales |
-| Testing | Vitest 5.x (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 |
@@ -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
@@ -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.
diff --git a/scripts/signing/verify-github-signatures.mjs b/scripts/signing/verify-github-signatures.mjs
index 019bfdf52..44e5dd8ad 100644
--- a/scripts/signing/verify-github-signatures.mjs
+++ b/scripts/signing/verify-github-signatures.mjs
@@ -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,
@@ -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,
});
diff --git a/scripts/signing/verify-remote.d.mts b/scripts/signing/verify-remote.d.mts
index 9e7b00fbe..3be7824cb 100644
--- a/scripts/signing/verify-remote.d.mts
+++ b/scripts/signing/verify-remote.d.mts
@@ -22,6 +22,10 @@ export interface RemoteResult {
reason: string;
}
+export interface VerifiedTagResult extends RemoteResult {
+ verifiedTagSha?: string;
+}
+
export interface RemoteOptions {
owner: string;
repo: string;
@@ -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;
export function verifyRemoteCommitRange(options: RemoteCommitRangeOptions): Promise;
export function verifyRemoteTag(options: {
owner: string;
repo: string;
tag: string;
+ expectedCommitSha: string;
token?: string;
fetchImpl?: typeof fetch;
-}): Promise;
+}): Promise;
diff --git a/scripts/signing/verify-remote.mjs b/scripts/signing/verify-remote.mjs
index 236620a78..fd9a102ef 100644
--- a/scripts/signing/verify-remote.mjs
+++ b/scripts/signing/verify-remote.mjs
@@ -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');
+ }
+
+ 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: {
@@ -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);
@@ -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') {
@@ -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),
@@ -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)}`,
@@ -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 };
diff --git a/tests/unit/signing.test.ts b/tests/unit/signing.test.ts
index b778dc2dc..ecc2d3df0 100644
--- a/tests/unit/signing.test.ts
+++ b/tests/unit/signing.test.ts
@@ -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,
diff --git a/tests/unit/tooling/verifyRemoteTag.test.ts b/tests/unit/tooling/verifyRemoteTag.test.ts
new file mode 100644
index 000000000..a1f9cfc38
--- /dev/null
+++ b/tests/unit/tooling/verifyRemoteTag.test.ts
@@ -0,0 +1,266 @@
+import { describe, expect, it, vi } from 'vitest';
+import { expectedTagCommitSha, verifyRemoteTag } from '../../../scripts/signing/verify-remote.mjs';
+
+const tagObjectSha = 'a'.repeat(40);
+const pushedCommitSha = 'b'.repeat(40);
+const retargetedCommitSha = 'c'.repeat(40);
+const changedTagObjectSha = 'd'.repeat(40);
+const newerMainSha = 'e'.repeat(40);
+const unrelatedMergeBaseSha = 'f'.repeat(40);
+
+type MainProvenanceOptions = {
+ mainProtected?: boolean;
+ mainSha?: string;
+ mainAfterProtected?: boolean;
+ mainAfterSha?: string;
+ compareStatus?: string;
+ compareBaseSha?: string;
+ compareMergeBaseSha?: string;
+ compareResponse?: unknown;
+ compareHttpStatus?: number;
+};
+
+function jsonResponse(value: unknown): Response {
+ return new Response(JSON.stringify(value), { status: 200 });
+}
+
+function tagFetch(
+ targetSha = pushedCommitSha,
+ tagVerified = true,
+ commitVerified = true,
+ options: MainProvenanceOptions = {},
+) {
+ const mainProtected = options.mainProtected ?? true;
+ const mainSha = options.mainSha ?? targetSha;
+ const compareStatus = options.compareStatus ?? (mainSha === targetSha ? 'identical' : 'ahead');
+ const compareBaseSha = options.compareBaseSha ?? targetSha;
+ const compareMergeBaseSha = options.compareMergeBaseSha ?? targetSha;
+ const compareResponse = options.compareResponse ?? {
+ status: compareStatus,
+ base_commit: { sha: compareBaseSha },
+ merge_base_commit: { sha: compareMergeBaseSha },
+ };
+ const compareResponseStatus = options.compareHttpStatus ?? 200;
+ return vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ object: { sha: tagObjectSha, type: 'tag' } }))
+ .mockResolvedValueOnce(
+ jsonResponse({
+ tag: 'v1.29.3',
+ object: { sha: targetSha, type: 'commit' },
+ verification: { verified: tagVerified, reason: tagVerified ? 'valid' : 'unsigned' },
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ sha: targetSha,
+ commit: {
+ message: 'release',
+ verification: { verified: commitVerified, reason: commitVerified ? 'valid' : 'unsigned' },
+ },
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({ name: 'main', protected: mainProtected, commit: { sha: mainSha } }),
+ )
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify(compareResponse), { status: compareResponseStatus }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ name: 'main',
+ protected: options.mainAfterProtected ?? mainProtected,
+ commit: { sha: options.mainAfterSha ?? mainSha },
+ }),
+ )
+ .mockResolvedValueOnce(jsonResponse({ object: { sha: tagObjectSha, type: 'tag' } }));
+}
+
+function verifyTag(fetchImpl: typeof fetch, expectedCommitSha = pushedCommitSha, tag = 'v1.29.3') {
+ return verifyRemoteTag({
+ owner: 'qnbs',
+ repo: 'WorldScript-Studio',
+ tag,
+ expectedCommitSha,
+ fetchImpl,
+ });
+}
+
+describe('release tag event binding', () => {
+ it('requires the push event target and github.sha to agree', () => {
+ expect(expectedTagCommitSha(pushedCommitSha, pushedCommitSha)).toBe(pushedCommitSha);
+ expect(() => expectedTagCommitSha(pushedCommitSha, retargetedCommitSha)).toThrow(
+ /does not match github\.sha/,
+ );
+ expect(() => expectedTagCommitSha('not-a-sha', pushedCommitSha)).toThrow(/full tag target/);
+ expect(() => expectedTagCommitSha('0'.repeat(40), '0'.repeat(40))).toThrow(/live tag target/);
+ });
+
+ it.each([
+ { name: 'identical protected main', options: { mainSha: pushedCommitSha } },
+ {
+ name: 'ahead protected main with the release commit as merge base',
+ options: { mainSha: newerMainSha },
+ },
+ ])('accepts a verified tag on $name', async ({ options }) => {
+ const fetchImpl = tagFetch(pushedCommitSha, true, true, options);
+ const result = await verifyTag(fetchImpl);
+
+ expect(result.ok).toBe(true);
+ expect(result.verifiedTagSha).toBe(pushedCommitSha);
+ expect(fetchImpl.mock.calls[4]?.[0]).toBe(
+ `https://api.github.com/repos/qnbs/WorldScript-Studio/compare/${pushedCommitSha}...${options.mainSha ?? pushedCommitSha}`,
+ );
+ expect(fetchImpl).toHaveBeenCalledTimes(7);
+ });
+
+ it('rejects a tag retargeted to a different commit before checking that commit', async () => {
+ const fetchImpl = tagFetch(retargetedCommitSha);
+ const result = await verifyTag(fetchImpl);
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toMatch(/does not match the pushed event commit/);
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
+ });
+
+ it('rejects an annotated tag object whose signed internal name differs from the pushed ref', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ object: { sha: tagObjectSha, type: 'tag' } }))
+ .mockResolvedValueOnce(
+ jsonResponse({
+ tag: 'v1.29.2',
+ object: { sha: pushedCommitSha, type: 'commit' },
+ verification: { verified: true, reason: 'valid' },
+ }),
+ );
+
+ await expect(verifyTag(fetchImpl)).rejects.toThrow(/tag name does not match the pushed ref/);
+ });
+
+ it('rejects a tag that moves while its signature is being checked', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ object: { sha: tagObjectSha, type: 'tag' } }))
+ .mockResolvedValueOnce(
+ jsonResponse({
+ tag: 'v1.29.3',
+ object: { sha: pushedCommitSha, type: 'commit' },
+ verification: { verified: true, reason: 'valid' },
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ sha: pushedCommitSha,
+ commit: { message: 'release', verification: { verified: true, reason: 'valid' } },
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({ name: 'main', protected: true, commit: { sha: pushedCommitSha } }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({
+ status: 'identical',
+ base_commit: { sha: pushedCommitSha },
+ merge_base_commit: { sha: pushedCommitSha },
+ }),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse({ name: 'main', protected: true, commit: { sha: pushedCommitSha } }),
+ )
+ .mockResolvedValueOnce(jsonResponse({ object: { sha: changedTagObjectSha, type: 'tag' } }));
+
+ await expect(verifyTag(fetchImpl)).rejects.toThrow(/changed during signature verification/);
+ });
+
+ it.each([
+ {
+ name: 'comparison marked behind',
+ options: { mainSha: newerMainSha, compareStatus: 'behind' },
+ error: /not contained in protected main/,
+ },
+ {
+ name: 'comparison marked diverged',
+ options: { mainSha: newerMainSha, compareStatus: 'diverged' },
+ error: /not contained in protected main/,
+ },
+ {
+ name: 'comparison with a different merge base',
+ options: { mainSha: newerMainSha, compareMergeBaseSha: unrelatedMergeBaseSha },
+ error: /not contained in protected main/,
+ },
+ {
+ name: 'comparison with a different requested base',
+ options: { compareBaseSha: unrelatedMergeBaseSha },
+ error: /not contained in protected main/,
+ },
+ {
+ name: 'comparison response without a merge base',
+ options: {
+ mainSha: newerMainSha,
+ compareResponse: { status: 'ahead', base_commit: { sha: pushedCommitSha } },
+ },
+ error: /not contained in protected main/,
+ },
+ {
+ name: 'comparison API failure',
+ options: { mainSha: newerMainSha, compareHttpStatus: 503 },
+ error: /GitHub API request failed \(503\)/,
+ },
+ {
+ name: 'unprotected main',
+ options: { mainProtected: false },
+ error: /protected main branch identity/,
+ },
+ ])('rejects $name', async ({ options, error }) => {
+ const fetchImpl = tagFetch(pushedCommitSha, true, true, options);
+
+ await expect(verifyTag(fetchImpl)).rejects.toThrow(error);
+ });
+
+ it.each([
+ { name: 'main moved during comparison', options: { mainAfterSha: unrelatedMergeBaseSha } },
+ { name: 'main lost protection during comparison', options: { mainAfterProtected: false } },
+ ])('rejects when $name', async ({ options }) => {
+ const fetchImpl = tagFetch(pushedCommitSha, true, true, {
+ mainSha: newerMainSha,
+ ...options,
+ });
+
+ await expect(verifyTag(fetchImpl)).rejects.toThrow(/protected main changed/);
+ expect(fetchImpl).toHaveBeenCalledTimes(6);
+ });
+
+ it('rejects a missing expected SHA before resolving a mutable tag ref', async () => {
+ const fetchImpl = vi.fn();
+ await expect(verifyTag(fetchImpl, '')).rejects.toThrow(/expected tag target/);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it('rejects lightweight tags even when the ref points to the pushed commit', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ object: { sha: pushedCommitSha, type: 'commit' } }));
+ const result = await verifyTag(fetchImpl);
+
+ expect(result.ok).toBe(false);
+ expect(result.reason).toMatch(/not an annotated tag/);
+ });
+
+ it.each([
+ ['an unsigned annotated tag', false, true],
+ ['an unsigned target commit', true, false],
+ ])('rejects %s', async (_label, tagVerified, commitVerified) => {
+ const result = await verifyTag(tagFetch(pushedCommitSha, tagVerified, commitVerified));
+
+ expect(result.ok).toBe(false);
+ expect(result.reports.some((report) => !report.verified)).toBe(true);
+ });
+
+ it('fails closed when the tag is missing or was deleted before lookup', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(new Response('not found', { status: 404 }));
+ await expect(verifyTag(fetchImpl)).rejects.toThrow(/GitHub API request failed \(404\)/);
+ });
+});