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 @@ TypeScript native preview (tsgo) Tauri 2 19 locales — 2963 keys - 8698+ tests / 655 files + 8705+ tests / 656 files Codecov Coverage CI status MIT License @@ -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\)/); + }); +});