@hraness/wordcell 0.25.1 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -43
- package/dist/capture.js +6 -6
- package/dist/cli-bin.js +14 -0
- package/dist/cli-release-cf48w61z.js +802 -0
- package/dist/cli.js +26 -23
- package/dist/clip/acquire.js +3 -3
- package/dist/clip/cli.js +6 -6
- package/dist/clip/doctor.js +4 -4
- package/dist/evaluation-builder-bin.js +14 -0
- package/dist/evaluation-builder.js +12 -17
- package/dist/evaluation-kb.js +6 -6
- package/dist/graph-percolation.js +2 -2
- package/dist/index-3t0v457d.js +18 -0
- package/dist/{index-47893rgw.js → index-5h5awwh4.js} +4 -4
- package/dist/index-6jz050v0.js +64 -0
- package/dist/index-8pabwzqg.js +909 -0
- package/dist/{index-1wccy765.js → index-8w1zsgcm.js} +63 -930
- package/dist/{index-7drercgk.js → index-9fywek7g.js} +1 -1
- package/dist/{index-hz2wyggk.js → index-9paxge84.js} +1 -1
- package/dist/{index-mcpppwbq.js → index-9zkba8hr.js} +6 -6
- package/dist/{index-59bcxqee.js → index-j2jt49gr.js} +1 -1
- package/dist/{index-nwrehke7.js → index-nd8v7r0z.js} +1 -1
- package/dist/{index-t3abkwk4.js → index-zbv70qmk.js} +15 -15
- package/dist/index.js +40 -40
- package/dist/pdf.js +2 -2
- package/dist/portfolio.js +5 -5
- package/dist/sdk.js +4 -4
- package/dist/{support-adj7bbge.js → support-ym4mpf7w.js} +19 -18
- package/dist/url-intelligence.js +3 -3
- package/dist/workflows/decision-context.js +5 -5
- package/dist/workflows/index.js +5 -5
- package/package.json +13 -6
- package/skills/wordcell/SKILL.md +10 -1
- package/src/cli-bin.ts +5 -0
- package/src/cli-help.ts +12 -0
- package/src/cli-program.ts +3 -57
- package/src/cli-release.ts +737 -0
- package/src/cli-startup.ts +76 -0
- package/src/cli-update.ts +53 -0
- package/src/cli.ts +5 -4
- package/src/evaluation-builder-bin.ts +5 -0
- package/src/evaluation-builder-cli-args.ts +30 -0
- package/src/evaluation-builder.ts +3 -27
- package/src/import-supermemory.ts +3 -1
- package/dist/{index-hxyjer3x.js → index-hacpnaew.js} +3 -3
- package/dist/{index-qfqjfxaa.js → index-m9p2a4mj.js} +4 -4
- package/dist/{index-xwxy71ew.js → index-ncwzmsge.js} +5 -5
- package/dist/{index-e26mdwxz.js → index-wqx1x32f.js} +3 -3
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, join, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
const repository = "hraness/wordcell";
|
|
9
|
+
const repositoryId = 1308971873;
|
|
10
|
+
const packageName = "@hraness/wordcell";
|
|
11
|
+
const maximumFileBytes = 128 * 1024 * 1024;
|
|
12
|
+
const metadataNames = ["npm-pack.json", "release-manifest.json", "SHA256SUMS"];
|
|
13
|
+
|
|
14
|
+
export type ReleaseManifest = Readonly<{
|
|
15
|
+
schema: "hraness-github-release-v1";
|
|
16
|
+
repository: "hraness/wordcell";
|
|
17
|
+
repositoryId: 1308971873;
|
|
18
|
+
package: "@hraness/wordcell";
|
|
19
|
+
version: string;
|
|
20
|
+
tag: string;
|
|
21
|
+
sourceSha: string;
|
|
22
|
+
workflow: ".github/workflows/release.yml";
|
|
23
|
+
workflowSha: string;
|
|
24
|
+
runId: number;
|
|
25
|
+
runAttempt: number;
|
|
26
|
+
archive: Readonly<{ name: string; bytes: number; sha256: string; sha512: string }>;
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
30
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
31
|
+
throw new Error(`${label} must be an object`);
|
|
32
|
+
}
|
|
33
|
+
return value as Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
|
|
37
|
+
if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...expected].sort())) {
|
|
38
|
+
throw new Error(`${label} has missing or unexpected fields`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function stableVersion(value: unknown): string {
|
|
43
|
+
if (typeof value !== "string") throw new Error("Release version must be a string");
|
|
44
|
+
const match = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u.exec(value);
|
|
45
|
+
if (match === null || match[0] !== value || match.slice(1).some((part) => BigInt(part) > BigInt(Number.MAX_SAFE_INTEGER))) {
|
|
46
|
+
throw new Error("Release version must have canonical safe stable components");
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function positive(value: unknown, label: string): number {
|
|
52
|
+
if (!Number.isSafeInteger(value) || (value as number) <= 0) throw new Error(`${label} must be a positive safe integer`);
|
|
53
|
+
return value as number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function exactHex(value: unknown, length: number): value is string {
|
|
57
|
+
return typeof value === "string" && value.length === length && /^[a-f0-9]+$/u.test(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseReleaseManifest(value: unknown): ReleaseManifest {
|
|
61
|
+
const manifest = record(value, "Release manifest");
|
|
62
|
+
exactKeys(manifest, ["schema", "repository", "repositoryId", "package", "version", "tag", "sourceSha", "workflow", "workflowSha", "runId", "runAttempt", "archive"], "Release manifest");
|
|
63
|
+
const version = stableVersion(manifest.version);
|
|
64
|
+
const archive = record(manifest.archive, "Release archive");
|
|
65
|
+
exactKeys(archive, ["name", "bytes", "sha256", "sha512"], "Release archive");
|
|
66
|
+
if (
|
|
67
|
+
manifest.schema !== "hraness-github-release-v1" || manifest.repository !== repository
|
|
68
|
+
|| manifest.repositoryId !== repositoryId || manifest.package !== packageName
|
|
69
|
+
|| manifest.tag !== `v${version}` || manifest.workflow !== ".github/workflows/release.yml"
|
|
70
|
+
|| !exactHex(manifest.sourceSha, 40)
|
|
71
|
+
|| !exactHex(manifest.workflowSha, 40)
|
|
72
|
+
|| archive.name !== `hraness-wordcell-${version}.tgz`
|
|
73
|
+
|| !exactHex(archive.sha256, 64)
|
|
74
|
+
|| !exactHex(archive.sha512, 128)
|
|
75
|
+
) throw new Error("Release manifest does not identify the canonical Wordcell artifact");
|
|
76
|
+
positive(manifest.runId, "Release run ID");
|
|
77
|
+
positive(manifest.runAttempt, "Release run attempt");
|
|
78
|
+
if (positive(archive.bytes, "Archive bytes") > maximumFileBytes) throw new Error("Release archive exceeds byte budget");
|
|
79
|
+
return manifest as ReleaseManifest;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hash(bytes: Uint8Array, algorithm = "sha256"): string {
|
|
83
|
+
return createHash(algorithm).update(bytes).digest("hex");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function boundedFile(path: string, maximum = maximumFileBytes): Promise<Buffer> {
|
|
87
|
+
const stat = await lstat(path);
|
|
88
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0 || stat.size > maximum) {
|
|
89
|
+
throw new Error(`Release file is not a bounded regular file: ${basename(path)}`);
|
|
90
|
+
}
|
|
91
|
+
const bytes = await readFile(path);
|
|
92
|
+
if (bytes.length !== stat.size) throw new Error("Release file changed during inspection");
|
|
93
|
+
return bytes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function verifyReleaseFiles(directory: string, includeProvenance = true): Promise<ReleaseManifest> {
|
|
97
|
+
const stat = await lstat(directory);
|
|
98
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Release directory is unsafe");
|
|
99
|
+
const manifest = parseReleaseManifest(JSON.parse((await boundedFile(join(directory, "release-manifest.json"), 16_384)).toString("utf8")) as unknown);
|
|
100
|
+
const expected = [manifest.archive.name, ...metadataNames, ...(includeProvenance ? ["provenance.jsonl"] : [])].sort();
|
|
101
|
+
if (JSON.stringify((await readdir(directory)).sort()) !== JSON.stringify(expected)) {
|
|
102
|
+
throw new Error("Release directory has missing or unexpected files");
|
|
103
|
+
}
|
|
104
|
+
const bytes = new Map<string, Buffer>();
|
|
105
|
+
for (const name of expected) bytes.set(name, await boundedFile(join(directory, name)));
|
|
106
|
+
const archive = bytes.get(manifest.archive.name)!;
|
|
107
|
+
if (archive.length !== manifest.archive.bytes || hash(archive) !== manifest.archive.sha256 || hash(archive, "sha512") !== manifest.archive.sha512) {
|
|
108
|
+
throw new Error("Release archive differs from the exact manifest bytes");
|
|
109
|
+
}
|
|
110
|
+
const packValue = JSON.parse(bytes.get("npm-pack.json")!.toString("utf8")) as unknown;
|
|
111
|
+
if (!Array.isArray(packValue) || packValue.length !== 1) throw new Error("Release pack receipt must contain exactly one package");
|
|
112
|
+
const pack = record(packValue[0], "Release pack receipt");
|
|
113
|
+
if (pack.name !== packageName || pack.version !== manifest.version || pack.filename !== manifest.archive.name
|
|
114
|
+
|| pack.size !== archive.length || pack.integrity !== `sha512-${createHash("sha512").update(archive).digest("base64")}`
|
|
115
|
+
|| pack.shasum !== hash(archive, "sha1")) throw new Error("Release pack receipt does not bind the canonical archive");
|
|
116
|
+
const checksumNames = [manifest.archive.name, "npm-pack.json", "release-manifest.json"];
|
|
117
|
+
const expectedChecksums = checksumNames.map((name) => `${hash(bytes.get(name)!)} ${name}\n`).join("");
|
|
118
|
+
if (bytes.get("SHA256SUMS")!.toString("utf8") !== expectedChecksums) throw new Error("Release checksums do not bind the exact artifact and identity");
|
|
119
|
+
return manifest;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Pre-standard releases (through 0.22.5) were published with a visible
|
|
123
|
+
// identity paragraph. Their tagged changelogs cannot reproduce standard notes,
|
|
124
|
+
// so they admit the exact legacy body or a standard page whose generated
|
|
125
|
+
// Install/Verify sections and trailing identity record are exact.
|
|
126
|
+
const lastPreStandardVersion = [0n, 22n, 5n] as const;
|
|
127
|
+
const identityMarker = "<!-- hraness-github-release-v1\n";
|
|
128
|
+
const maximumChangelogBytes = 4 * 1024 * 1024;
|
|
129
|
+
|
|
130
|
+
function versionParts(version: string): readonly [bigint, bigint, bigint] {
|
|
131
|
+
const [major, minor, patch] = stableVersion(version).split(".").map(BigInt);
|
|
132
|
+
return [major!, minor!, patch!];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isPreStandardRelease(version: string): boolean {
|
|
136
|
+
const parts = versionParts(version);
|
|
137
|
+
for (let index = 0; index < 3; index += 1) {
|
|
138
|
+
if (parts[index]! !== lastPreStandardVersion[index]) return parts[index]! < lastPreStandardVersion[index]!;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type ChangelogSection = Readonly<{ summary: string; changes: string }>;
|
|
144
|
+
|
|
145
|
+
/** Copy one version's summary paragraph(s) and bullet list from CHANGELOG.md. */
|
|
146
|
+
export function changelogSection(changelog: string, version: string): ChangelogSection {
|
|
147
|
+
stableVersion(version);
|
|
148
|
+
if (changelog.includes("\r")) throw new Error("CHANGELOG.md must use LF line endings");
|
|
149
|
+
const lines = changelog.split("\n");
|
|
150
|
+
const escaped = version.replaceAll(".", "\\.");
|
|
151
|
+
const heading = new RegExp(`^## v?${escaped}(?: - [0-9]{4}-[0-9]{2}-[0-9]{2})?$`, "u");
|
|
152
|
+
const starts = lines.flatMap((line, index) => heading.test(line) ? [index] : []);
|
|
153
|
+
if (starts.length === 0) throw new Error(`CHANGELOG.md has no section for ${version}`);
|
|
154
|
+
if (starts.length > 1) throw new Error(`CHANGELOG.md repeats the section for ${version}`);
|
|
155
|
+
const start = starts[0]! + 1;
|
|
156
|
+
let end = lines.findIndex((line, index) => index >= start && /^#{1,2} /u.test(line));
|
|
157
|
+
if (end < 0) end = lines.length;
|
|
158
|
+
const body = lines.slice(start, end);
|
|
159
|
+
const text = body.join("\n").trim();
|
|
160
|
+
if (text === "") throw new Error(`CHANGELOG.md section for ${version} is empty`);
|
|
161
|
+
if (/\bunreleased\b/iu.test(text)) throw new Error(`CHANGELOG.md section for ${version} still says Unreleased`);
|
|
162
|
+
if (body.some((line) => /^#/u.test(line))) throw new Error(`CHANGELOG.md section for ${version} must not contain headings`);
|
|
163
|
+
const first = body.findIndex((line) => line.startsWith("- "));
|
|
164
|
+
if (first < 0) throw new Error(`CHANGELOG.md section for ${version} has no bulleted changes`);
|
|
165
|
+
const summary = body.slice(0, first).join("\n").trim();
|
|
166
|
+
if (summary === "") throw new Error(`CHANGELOG.md section for ${version} has no summary`);
|
|
167
|
+
const list = body.slice(first).join("\n").trim().split("\n");
|
|
168
|
+
if (list.some((line) => line !== "" && !line.startsWith("- ") && !line.startsWith(" "))) {
|
|
169
|
+
throw new Error(`CHANGELOG.md section for ${version} must end with its bulleted changes`);
|
|
170
|
+
}
|
|
171
|
+
if (list.some((line) => line.trim() === "-")) throw new Error(`CHANGELOG.md section for ${version} has an empty change`);
|
|
172
|
+
return { summary, changes: list.join("\n") };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function generatedSections(manifest: ReleaseManifest): string {
|
|
176
|
+
const download = `https://github.com/${repository}/releases/download/${manifest.tag}`;
|
|
177
|
+
return [
|
|
178
|
+
"## Install",
|
|
179
|
+
"",
|
|
180
|
+
"Install this version from its GitHub Release archive with Bun:",
|
|
181
|
+
"",
|
|
182
|
+
"```sh",
|
|
183
|
+
`bun add --global --ignore-scripts ${download}/${manifest.archive.name}`,
|
|
184
|
+
"```",
|
|
185
|
+
"",
|
|
186
|
+
"The same archive is mirrored on npm:",
|
|
187
|
+
"",
|
|
188
|
+
"```sh",
|
|
189
|
+
`npm install --global --ignore-scripts ${packageName}@${manifest.version}`,
|
|
190
|
+
"```",
|
|
191
|
+
"",
|
|
192
|
+
"## Verify",
|
|
193
|
+
"",
|
|
194
|
+
`- Checksums: [\`SHA256SUMS\`](${download}/SHA256SUMS) lists the SHA-256 of the archive, \`npm-pack.json\`, and \`release-manifest.json\`. The archive's SHA-256 is \`${manifest.archive.sha256}\`.`,
|
|
195
|
+
`- Source commit: [\`${manifest.sourceSha}\`](https://github.com/${repository}/commit/${manifest.sourceSha})`,
|
|
196
|
+
`- Signed build provenance is attached as \`provenance.jsonl\`. To check it, follow [Verify a published release](https://github.com/${repository}/blob/${manifest.tag}/docs/publishing.md#verify-a-published-release).`,
|
|
197
|
+
].join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function releaseTitle(manifest: ReleaseManifest): string {
|
|
201
|
+
return `Wordcell ${manifest.tag}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The machine-readable identity record: the final bytes of every release body. */
|
|
205
|
+
export function releaseIdentity(manifest: ReleaseManifest): string {
|
|
206
|
+
return `${identityMarker}Package: ${packageName}@${manifest.version}\nSource commit: ${manifest.sourceSha}\nWorkflow run: ${manifest.runId}\nWorkflow attempt: ${manifest.runAttempt}\nArchive SHA-256: ${manifest.archive.sha256}\n-->`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Visible release notes: changelog summary and changes, then generated Install and Verify. */
|
|
210
|
+
export function releaseNotes(manifest: ReleaseManifest, changelog: string): string {
|
|
211
|
+
const section = changelogSection(changelog, manifest.version);
|
|
212
|
+
return `${section.summary}\n\n## Changes\n\n${section.changes}\n\n${generatedSections(manifest)}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function releaseBody(manifest: ReleaseManifest, changelog: string): string {
|
|
216
|
+
return `${releaseNotes(manifest, changelog)}\n\n${releaseIdentity(manifest)}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The pre-standard visible body, admitted only for versions through 0.22.5. */
|
|
220
|
+
export function legacyReleaseBody(manifest: ReleaseManifest): string {
|
|
221
|
+
return `Canonical GitHub release for ${packageName}@${manifest.version}.\n\nSource commit: ${manifest.sourceSha}\nWorkflow run: ${manifest.runId}\nWorkflow attempt: ${manifest.runAttempt}\nArchive SHA-256: ${manifest.archive.sha256}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export type ReleaseIdentity = Readonly<{ version: string; sourceSha: string; runId: number; runAttempt: number; archiveSha256: string }>;
|
|
225
|
+
|
|
226
|
+
/** Split a body at the last identity marker and parse the trailing record. */
|
|
227
|
+
export function parseReleaseBody(body: unknown): Readonly<{ notes: string; identity: ReleaseIdentity }> {
|
|
228
|
+
if (typeof body !== "string") throw new Error("Release body must be a string");
|
|
229
|
+
if (!body.endsWith("-->")) throw new Error("Release body must end with its identity record");
|
|
230
|
+
const start = body.lastIndexOf(identityMarker);
|
|
231
|
+
if (start < 0) throw new Error("Release body has no identity record");
|
|
232
|
+
const match = /^Package: @hraness\/wordcell@([0-9.]+)\nSource commit: ([a-f0-9]{40})\nWorkflow run: ([1-9][0-9]*)\nWorkflow attempt: ([1-9][0-9]*)\nArchive SHA-256: ([a-f0-9]{64})\n-->$/u
|
|
233
|
+
.exec(body.slice(start + identityMarker.length));
|
|
234
|
+
if (match === null) throw new Error("Release identity record is malformed");
|
|
235
|
+
const identity = {
|
|
236
|
+
version: stableVersion(match[1]), sourceSha: match[2]!,
|
|
237
|
+
runId: positive(Number(match[3]), "Identity run ID"), runAttempt: positive(Number(match[4]), "Identity run attempt"),
|
|
238
|
+
archiveSha256: match[5]!,
|
|
239
|
+
};
|
|
240
|
+
if (String(identity.runId) !== match[3] || String(identity.runAttempt) !== match[4]) throw new Error("Release identity record is malformed");
|
|
241
|
+
const notes = body.slice(0, start);
|
|
242
|
+
if (!notes.endsWith("\n\n") || notes.trim() === "") throw new Error("Release body has no notes above its identity record");
|
|
243
|
+
return { notes: notes.slice(0, -2), identity };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Admit a provider release body. Standard releases must byte-match the tagged
|
|
248
|
+
* changelog section plus generated sections; `changelog` is the tagged
|
|
249
|
+
* commit's CHANGELOG.md, or undefined only for pre-standard versions.
|
|
250
|
+
*/
|
|
251
|
+
export function verifyReleaseBody(body: unknown, manifest: ReleaseManifest, changelog: string | undefined): void {
|
|
252
|
+
const preStandard = isPreStandardRelease(manifest.version);
|
|
253
|
+
if (preStandard && body === legacyReleaseBody(manifest)) return;
|
|
254
|
+
const { notes, identity } = parseReleaseBody(body);
|
|
255
|
+
if (identity.version !== manifest.version || identity.sourceSha !== manifest.sourceSha || identity.runId !== manifest.runId
|
|
256
|
+
|| identity.runAttempt !== manifest.runAttempt || identity.archiveSha256 !== manifest.archive.sha256
|
|
257
|
+
|| !(body as string).endsWith(releaseIdentity(manifest))) {
|
|
258
|
+
throw new Error("Release identity record differs from the verified manifest");
|
|
259
|
+
}
|
|
260
|
+
if (preStandard) {
|
|
261
|
+
const generated = `\n\n${generatedSections(manifest)}`;
|
|
262
|
+
if (!notes.endsWith(generated) || !notes.includes("\n\n## Changes\n\n- ") || notes.startsWith("#")) {
|
|
263
|
+
throw new Error("Release notes differ from the generated release sections");
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (changelog === undefined) throw new Error("Standard release verification requires the tagged CHANGELOG.md");
|
|
268
|
+
if (notes !== releaseNotes(manifest, changelog)) throw new Error("Release notes differ from the tagged changelog section and generated sections");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Read CHANGELOG.md from the exact tagged commit in the local repository. */
|
|
272
|
+
export function taggedChangelog(sourceSha: string): string {
|
|
273
|
+
if (!exactHex(sourceSha, 40)) throw new Error("Tagged changelog source must be a full commit");
|
|
274
|
+
return execFileSync("git", ["show", `${sourceSha}:CHANGELOG.md`], {
|
|
275
|
+
encoding: "utf8", timeout: 30_000, maxBuffer: maximumChangelogBytes, stdio: ["ignore", "pipe", "pipe"],
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export type AssetIdentity = Readonly<{ name: string; bytes: number; sha256: string }>;
|
|
280
|
+
|
|
281
|
+
function exactAssetBrowserUrl(value: unknown, name: string, tag: string, draft: boolean): boolean {
|
|
282
|
+
const prefix = `https://github.com/${repository}/releases/download/`;
|
|
283
|
+
if (value === `${prefix}${tag}/${name}`) return true;
|
|
284
|
+
if (!draft || typeof value !== "string" || !value.startsWith(`${prefix}untagged-`)
|
|
285
|
+
|| !value.endsWith(`/${name}`)) return false;
|
|
286
|
+
const temporaryId = value.slice(`${prefix}untagged-`.length, -(`/${name}`.length));
|
|
287
|
+
return /^[a-f0-9]{20}$/u.test(temporaryId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function verifyProviderRelease(value: unknown, manifest: ReleaseManifest, assets: readonly AssetIdentity[], allowDraft: boolean, changelog: string | undefined): readonly string[] {
|
|
291
|
+
const release = record(value, "GitHub Release");
|
|
292
|
+
const author = record(release.author, "Release author");
|
|
293
|
+
try { verifyReleaseBody(release.body, manifest, changelog); } catch (error) {
|
|
294
|
+
throw new Error(`GitHub Release is not the exact Actions-authored artifact; reconcile the original run before retrying (${(error as Error).message})`);
|
|
295
|
+
}
|
|
296
|
+
if (release.tag_name !== manifest.tag || release.target_commitish !== manifest.sourceSha || release.name !== releaseTitle(manifest)
|
|
297
|
+
|| release.prerelease !== false || (!allowDraft && (release.draft !== false || release.immutable !== true))
|
|
298
|
+
|| (allowDraft && release.draft !== true && (release.draft !== false || release.immutable !== true))
|
|
299
|
+
|| (release.draft === true && release.immutable !== false)
|
|
300
|
+
|| author.id !== 41898282 || author.login !== "github-actions[bot]" || author.type !== "Bot"
|
|
301
|
+
|| !Array.isArray(release.assets)) throw new Error("GitHub Release is not the exact Actions-authored artifact; reconcile the original run before retrying");
|
|
302
|
+
positive(release.id, "Release ID");
|
|
303
|
+
const present = new Set<string>();
|
|
304
|
+
const assetIds = new Set<number>();
|
|
305
|
+
for (const item of release.assets) {
|
|
306
|
+
const asset = record(item, "GitHub asset");
|
|
307
|
+
const expected = assets.find((candidate) => candidate.name === asset.name);
|
|
308
|
+
if (expected === undefined || present.has(expected.name) || asset.size !== expected.bytes
|
|
309
|
+
|| asset.digest !== `sha256:${expected.sha256}` || asset.state !== "uploaded"
|
|
310
|
+
|| !exactAssetBrowserUrl(asset.browser_download_url, expected.name, manifest.tag, release.draft === true)
|
|
311
|
+
|| asset.url !== `https://api.github.com/repos/${repository}/releases/assets/${String(asset.id)}`) {
|
|
312
|
+
throw new Error("GitHub release has an unexpected, duplicate, or mismatched asset");
|
|
313
|
+
}
|
|
314
|
+
const id = positive(asset.id, "Release asset ID");
|
|
315
|
+
if (assetIds.has(id)) throw new Error("GitHub release repeats an asset ID");
|
|
316
|
+
assetIds.add(id);
|
|
317
|
+
present.add(expected.name);
|
|
318
|
+
}
|
|
319
|
+
const missing = assets.filter((asset) => !present.has(asset.name)).map((asset) => asset.name);
|
|
320
|
+
if (release.draft === false && missing.length !== 0) throw new Error("Published release is missing canonical assets");
|
|
321
|
+
return missing;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function command(program: string, args: readonly string[]): string {
|
|
325
|
+
return execFileSync(program, [...args], {
|
|
326
|
+
encoding: "utf8", timeout: 90_000, maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"],
|
|
327
|
+
env: program === "gh" ? { ...process.env, GH_HOST: "github.com", GH_PROMPT_DISABLED: "1" } : process.env,
|
|
328
|
+
}).trim();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function binaryAsset(id: number, expectedBytes: number): Buffer {
|
|
332
|
+
return execFileSync("gh", ["api", "--method", "GET", `/repos/${repository}/releases/assets/${id}`,
|
|
333
|
+
"-H", "Accept: application/octet-stream"], {
|
|
334
|
+
timeout: 90_000, maxBuffer: expectedBytes + 1, stdio: ["ignore", "pipe", "pipe"],
|
|
335
|
+
env: { ...process.env, GH_HOST: "github.com", GH_PROMPT_DISABLED: "1" },
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function api(path: string): unknown {
|
|
340
|
+
return JSON.parse(command("gh", ["api", "--method", "GET", path])) as unknown;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function verifyAttestationRun(value: unknown, manifest: ReleaseManifest, subjects: readonly AssetIdentity[]): void {
|
|
344
|
+
if (!Array.isArray(value) || value.length === 0) throw new Error("No verified GitHub attestations returned");
|
|
345
|
+
const names = [manifest.archive.name, ...metadataNames].sort();
|
|
346
|
+
if (subjects.length !== 4 || JSON.stringify(subjects.map((subject) => subject.name).sort()) !== JSON.stringify(names)) throw new Error("Expected exactly four canonical attestation subjects");
|
|
347
|
+
const invocation = `https://github.com/${repository}/actions/runs/${manifest.runId}/attempts/${manifest.runAttempt}`;
|
|
348
|
+
if (!value.some((item: unknown) => {
|
|
349
|
+
const result = record(item, "Verified attestation");
|
|
350
|
+
const verification = record(result.verificationResult, "Attestation verification result");
|
|
351
|
+
const signature = record(verification.signature, "Verified signature");
|
|
352
|
+
const certificate = record(signature.certificate, "Verified signing certificate");
|
|
353
|
+
const workflowUri = `https://github.com/${repository}/${manifest.workflow}@refs/tags/${manifest.tag}`;
|
|
354
|
+
if (certificate.runInvocationURI !== invocation
|
|
355
|
+
|| certificate.issuer !== "https://token.actions.githubusercontent.com"
|
|
356
|
+
|| certificate.sourceRepositoryIdentifier !== String(repositoryId)
|
|
357
|
+
|| certificate.sourceRepositoryOwnerIdentifier !== "307125679"
|
|
358
|
+
|| certificate.sourceRepositoryOwnerURI !== "https://github.com/hraness"
|
|
359
|
+
|| certificate.sourceRepositoryURI !== `https://github.com/${repository}`
|
|
360
|
+
|| certificate.sourceRepositoryDigest !== manifest.sourceSha
|
|
361
|
+
|| certificate.sourceRepositoryRef !== `refs/tags/${manifest.tag}`
|
|
362
|
+
|| certificate.buildSignerDigest !== manifest.sourceSha || certificate.buildConfigDigest !== manifest.sourceSha
|
|
363
|
+
|| certificate.buildSignerURI !== workflowUri || certificate.buildConfigURI !== workflowUri
|
|
364
|
+
|| certificate.runnerEnvironment !== "github-hosted" || certificate.buildTrigger !== "push"
|
|
365
|
+
|| certificate.sourceRepositoryVisibilityAtSigning !== "public") return false;
|
|
366
|
+
if (!Array.isArray(verification.verifiedTimestamps) || verification.verifiedTimestamps.length === 0) return false;
|
|
367
|
+
const statement = record(verification.statement, "Verified attestation statement");
|
|
368
|
+
if (statement._type !== "https://in-toto.io/Statement/v1" || statement.predicateType !== "https://slsa.dev/provenance/v1"
|
|
369
|
+
|| !Array.isArray(statement.subject) || statement.subject.length !== 4) return false;
|
|
370
|
+
const found = new Set<string>();
|
|
371
|
+
return statement.subject.every((candidate: unknown) => {
|
|
372
|
+
const item = record(candidate, "Verified subject");
|
|
373
|
+
const digest = record(item.digest, "Verified subject digest");
|
|
374
|
+
const expected = subjects.find((subject) => subject.name === item.name);
|
|
375
|
+
if (expected === undefined || found.has(expected.name) || digest.sha256 !== expected.sha256 || Object.keys(digest).length !== 1) return false;
|
|
376
|
+
found.add(expected.name);
|
|
377
|
+
return true;
|
|
378
|
+
});
|
|
379
|
+
})) throw new Error("Verified GitHub attestation does not bind the release run and attempt");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function verifyAttestations(directory: string, manifest: ReleaseManifest): void {
|
|
383
|
+
const subjects = [manifest.archive.name, ...metadataNames].map((name) => {
|
|
384
|
+
const bytes = readFileSync(join(directory, name));
|
|
385
|
+
return { name, bytes: bytes.length, sha256: hash(bytes) };
|
|
386
|
+
});
|
|
387
|
+
for (const name of [manifest.archive.name, ...metadataNames]) {
|
|
388
|
+
const result = JSON.parse(command("gh", ["attestation", "verify", join(directory, name),
|
|
389
|
+
"--repo", repository, "--signer-workflow", `${repository}/.github/workflows/release.yml`,
|
|
390
|
+
"--signer-digest", manifest.sourceSha, "--source-digest", manifest.sourceSha,
|
|
391
|
+
"--source-ref", `refs/tags/${manifest.tag}`, "--deny-self-hosted-runners",
|
|
392
|
+
"--bundle", join(directory, "provenance.jsonl"), "--format", "json"])) as unknown;
|
|
393
|
+
verifyAttestationRun(result, manifest, subjects);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const canonicalJobs = ["Authorize owner release tag", "Verify", "Attest verified artifact", "Publish"];
|
|
398
|
+
const npmJobs = ["Publish exact npm package", "Admit the public npm package"];
|
|
399
|
+
|
|
400
|
+
// The owner, or the hraness-release-tagger App bot that tags version bumps on main.
|
|
401
|
+
function releaseActor(actor: Record<string, unknown>): boolean {
|
|
402
|
+
return (actor.id === 894119 && actor.type === "User") || (actor.id === 337004703 && actor.type === "Bot");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function canonicalRunIdentity(value: unknown, manifest: ReleaseManifest, attempt: number): Record<string, unknown> {
|
|
406
|
+
const run = record(value, "Canonical release run");
|
|
407
|
+
const owner = record(run.actor, "Canonical release actor");
|
|
408
|
+
const triggering = record(run.triggering_actor, "Canonical triggering actor");
|
|
409
|
+
const source = record(run.repository, "Canonical run repository");
|
|
410
|
+
if (run.id !== manifest.runId || run.run_attempt !== attempt
|
|
411
|
+
|| run.workflow_id !== 320004141 || run.name !== "Release" || run.path !== manifest.workflow
|
|
412
|
+
|| run.status !== "completed" || !["success", "failure"].includes(String(run.conclusion)) || run.event !== "push"
|
|
413
|
+
|| run.head_branch !== manifest.tag || run.head_sha !== manifest.sourceSha
|
|
414
|
+
|| !releaseActor(owner) || !releaseActor(triggering)
|
|
415
|
+
|| source.id !== repositoryId || source.full_name !== repository || source.private !== false) {
|
|
416
|
+
throw new Error("Canonical release does not have the exact completed source and owner identity");
|
|
417
|
+
}
|
|
418
|
+
return run;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function canonicalJobInventory(value: unknown, manifest: ReleaseManifest, minimumAttempt: number, maximumAttempt: number, conclusion: unknown): void {
|
|
422
|
+
const inventory = record(value, "Canonical jobs inventory");
|
|
423
|
+
const expected = [...canonicalJobs, ...npmJobs];
|
|
424
|
+
if (!Array.isArray(inventory.jobs) || inventory.total_count !== expected.length || inventory.jobs.length !== expected.length) {
|
|
425
|
+
throw new Error("Canonical jobs must contain the complete bounded workflow inventory");
|
|
426
|
+
}
|
|
427
|
+
const names = new Set<string>();
|
|
428
|
+
const ids = new Set<number>();
|
|
429
|
+
let npmFailed = false;
|
|
430
|
+
for (const value of inventory.jobs) {
|
|
431
|
+
const job = record(value, "Canonical job");
|
|
432
|
+
const id = positive(job.id, "Canonical job ID");
|
|
433
|
+
const attempt = positive(job.run_attempt, "Canonical job attempt");
|
|
434
|
+
if (typeof job.name !== "string" || !expected.includes(job.name) || names.has(job.name) || ids.has(id)
|
|
435
|
+
|| job.run_id !== manifest.runId || job.head_sha !== manifest.sourceSha
|
|
436
|
+
|| attempt < minimumAttempt || attempt > maximumAttempt || job.status !== "completed") {
|
|
437
|
+
throw new Error("Canonical job identity, attempt, or terminal state is invalid");
|
|
438
|
+
}
|
|
439
|
+
names.add(job.name); ids.add(id);
|
|
440
|
+
if (canonicalJobs.includes(job.name)) {
|
|
441
|
+
if (job.conclusion !== "success") throw new Error("Every canonical source, attestation, and publication job must succeed");
|
|
442
|
+
} else {
|
|
443
|
+
if (!["success", "failure", "skipped"].includes(String(job.conclusion))) throw new Error("npm job has an unsafe terminal state");
|
|
444
|
+
npmFailed ||= job.conclusion === "failure";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if ((conclusion === "failure") !== npmFailed) throw new Error("Aggregate conclusion is not explained by the exact npm job results");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export type CanonicalRunProof = Readonly<{ originalJobs: unknown; latestRun: unknown; latestJobs: unknown }>;
|
|
451
|
+
|
|
452
|
+
export function verifyCanonicalRun(value: unknown, manifest: ReleaseManifest, proof: CanonicalRunProof): void {
|
|
453
|
+
const original = canonicalRunIdentity(value, manifest, manifest.runAttempt);
|
|
454
|
+
const latestAttempt = positive(record(proof.latestRun, "Latest release run").run_attempt, "Latest release attempt");
|
|
455
|
+
if (latestAttempt < manifest.runAttempt) throw new Error("Latest release attempt predates the signed receipt");
|
|
456
|
+
const latest = canonicalRunIdentity(proof.latestRun, manifest, latestAttempt);
|
|
457
|
+
canonicalJobInventory(proof.originalJobs, manifest, manifest.runAttempt, manifest.runAttempt, original.conclusion);
|
|
458
|
+
// GitHub may give inherited successes fresh IDs and the new attempt number on
|
|
459
|
+
// failed-only reruns. Admit the provider's complete effective inventory, not
|
|
460
|
+
// equality with earlier job IDs; the signed receipt retains its own attempt.
|
|
461
|
+
canonicalJobInventory(proof.latestJobs, manifest, manifest.runAttempt, latestAttempt, latest.conclusion);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function verifyCanonicalPublication(manifest: ReleaseManifest, read: (path: string) => unknown = api): void {
|
|
465
|
+
const endpoint = `/repos/${repository}/actions/runs/${manifest.runId}`;
|
|
466
|
+
const original = read(`${endpoint}/attempts/${manifest.runAttempt}`);
|
|
467
|
+
const originalJobs = read(`${endpoint}/attempts/${manifest.runAttempt}/jobs?per_page=100`);
|
|
468
|
+
const latestRun = read(endpoint);
|
|
469
|
+
const latestJobs = read(`${endpoint}/jobs?filter=latest&per_page=100`);
|
|
470
|
+
verifyCanonicalRun(original, manifest, { originalJobs, latestRun, latestJobs });
|
|
471
|
+
const after = read(endpoint);
|
|
472
|
+
const attempt = positive(record(latestRun, "Latest release run").run_attempt, "Latest release attempt");
|
|
473
|
+
const final = canonicalRunIdentity(after, manifest, attempt);
|
|
474
|
+
if (final.conclusion !== record(latestRun, "Latest release run").conclusion) throw new Error("Release result changed during canonical admission");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function assetIdentities(directory: string): Promise<readonly AssetIdentity[]> {
|
|
478
|
+
return Promise.all((await readdir(directory)).sort().map(async (name) => {
|
|
479
|
+
const bytes = await boundedFile(join(directory, name));
|
|
480
|
+
return { name, bytes: bytes.length, sha256: hash(bytes) };
|
|
481
|
+
}));
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function prepare(directory: string): Promise<void> {
|
|
485
|
+
const version = stableVersion(process.env.RELEASE_VERSION);
|
|
486
|
+
const archiveName = `hraness-wordcell-${version}.tgz`;
|
|
487
|
+
const archive = await boundedFile(join(directory, archiveName));
|
|
488
|
+
const manifest = parseReleaseManifest({
|
|
489
|
+
schema: "hraness-github-release-v1", repository, repositoryId, package: packageName,
|
|
490
|
+
version, tag: `v${version}`, sourceSha: process.env.VERIFIED_SOURCE_SHA,
|
|
491
|
+
workflow: ".github/workflows/release.yml", workflowSha: process.env.WORKFLOW_SHA,
|
|
492
|
+
runId: Number(process.env.GITHUB_RUN_ID), runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT),
|
|
493
|
+
archive: { name: archiveName, bytes: archive.length, sha256: hash(archive), sha512: hash(archive, "sha512") },
|
|
494
|
+
});
|
|
495
|
+
await writeFile(join(directory, "release-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { flag: "wx" });
|
|
496
|
+
const sums: string[] = [];
|
|
497
|
+
for (const name of [archiveName, "npm-pack.json", "release-manifest.json"]) {
|
|
498
|
+
sums.push(`${hash(await boundedFile(join(directory, name)))} ${name}\n`);
|
|
499
|
+
}
|
|
500
|
+
await writeFile(join(directory, "SHA256SUMS"), sums.join(""), { flag: "wx" });
|
|
501
|
+
await verifyReleaseFiles(directory, false);
|
|
502
|
+
// Fail source verification, before attestation or any release exists, when
|
|
503
|
+
// the tagged CHANGELOG.md cannot supply this version's notes.
|
|
504
|
+
releaseBody(manifest, taggedChangelog(manifest.sourceSha));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function verifyCurrentControls(manifest: ReleaseManifest): void {
|
|
508
|
+
const ref = record(api(`/repos/${repository}/git/ref/heads/main`), "Current main ref");
|
|
509
|
+
const object = record(ref.object, "Current main object");
|
|
510
|
+
if (object.type !== "commit" || !exactHex(object.sha, 40)) throw new Error("Current main identity is malformed");
|
|
511
|
+
command("git", ["fetch", "--no-tags", "--force", "origin", "refs/heads/main:refs/remotes/kb-release-current/main"]);
|
|
512
|
+
const current = command("git", ["rev-parse", "refs/remotes/kb-release-current/main"]);
|
|
513
|
+
if (current !== object.sha) throw new Error("Current main moved during release authorization");
|
|
514
|
+
command("git", ["merge-base", "--is-ancestor", manifest.sourceSha, current]);
|
|
515
|
+
command("git", ["merge-base", "--is-ancestor", manifest.workflowSha, current]);
|
|
516
|
+
const controls = [".github/workflows/release.yml", "scripts/github-release.ts", "src/cli-release.ts", "scripts/package-artifact.ts", "scripts/npm-package-identity.ts", "scripts/package-smoke.ts", "scripts/prepare-npm-package.ts"];
|
|
517
|
+
for (const authority of [manifest.sourceSha, manifest.workflowSha]) {
|
|
518
|
+
command("git", ["diff", "--quiet", "--no-ext-diff", "--no-textconv", authority, current, "--", ...controls]);
|
|
519
|
+
}
|
|
520
|
+
const tagRef = record(api(`/repos/${repository}/git/ref/tags/${manifest.tag}`), "Current release tag");
|
|
521
|
+
const tagObject = record(tagRef.object, "Current annotated tag");
|
|
522
|
+
if (tagObject.type !== "tag" || !exactHex(tagObject.sha, 40)) throw new Error("Release tag is no longer annotated");
|
|
523
|
+
const tag = record(api(`/repos/${repository}/git/tags/${tagObject.sha}`), "Current tag identity");
|
|
524
|
+
const source = record(tag.object, "Current tag source");
|
|
525
|
+
if (source.type !== "commit" || source.sha !== manifest.sourceSha || tag.tag !== manifest.tag) throw new Error("Release tag changed after verification");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function uniqueReleaseId(pages: unknown, tag: string): number | undefined {
|
|
529
|
+
if (!Array.isArray(pages) || pages.length === 0 || pages.length > 1_000) {
|
|
530
|
+
throw new Error("Authenticated release inventory is malformed or exceeds its bound");
|
|
531
|
+
}
|
|
532
|
+
const identities = new Set<number>();
|
|
533
|
+
const matches: number[] = [];
|
|
534
|
+
for (const page of pages) {
|
|
535
|
+
if (!Array.isArray(page) || page.length > 100) throw new Error("Authenticated release page is malformed");
|
|
536
|
+
for (const value of page) {
|
|
537
|
+
const release = record(value, "Listed release");
|
|
538
|
+
const id = positive(release.id, "Listed release ID");
|
|
539
|
+
if (identities.has(id)) throw new Error("Authenticated release inventory repeats an ID");
|
|
540
|
+
identities.add(id);
|
|
541
|
+
if (typeof release.tag_name !== "string") throw new Error("Listed release tag is malformed");
|
|
542
|
+
if (release.tag_name === tag) matches.push(id);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (matches.length > 1) throw new Error("Multiple GitHub releases claim the exact tag");
|
|
546
|
+
return matches[0];
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export function publishVerifiedRelease(
|
|
550
|
+
directory: string,
|
|
551
|
+
manifest: ReleaseManifest,
|
|
552
|
+
assets: readonly AssetIdentity[],
|
|
553
|
+
changelog: string,
|
|
554
|
+
run: (program: string, args: readonly string[]) => string = command,
|
|
555
|
+
authorize: () => void = () => verifyCurrentControls(manifest),
|
|
556
|
+
download: (id: number, expectedBytes: number) => Uint8Array = binaryAsset,
|
|
557
|
+
): void {
|
|
558
|
+
// Render before any provider read or write so a missing, empty, or
|
|
559
|
+
// Unreleased changelog section stops publication before a draft exists.
|
|
560
|
+
const body = releaseBody(manifest, changelog);
|
|
561
|
+
const read = (path: string): unknown => JSON.parse(run("gh", ["api", "--method", "GET", path])) as unknown;
|
|
562
|
+
const discover = (): number | undefined => uniqueReleaseId(JSON.parse(run("gh", [
|
|
563
|
+
"api", "--method", "GET", `/repos/${repository}/releases?per_page=100`, "--paginate", "--slurp",
|
|
564
|
+
])) as unknown, manifest.tag);
|
|
565
|
+
// GitHub's by-tag endpoint can return 404 for an existing draft. Enumerate
|
|
566
|
+
// authenticated releases and retain one exact ID throughout its lifecycle.
|
|
567
|
+
let releaseId = discover();
|
|
568
|
+
if (releaseId === undefined) {
|
|
569
|
+
authorize();
|
|
570
|
+
const response = run("gh", ["api", "--method", "POST", `/repos/${repository}/releases`, "--include",
|
|
571
|
+
"-f", `tag_name=${manifest.tag}`, "-f", `target_commitish=${manifest.sourceSha}`,
|
|
572
|
+
"-f", `name=${releaseTitle(manifest)}`, "-f", `body=${body}`,
|
|
573
|
+
"-F", "draft=true", "-F", "prerelease=false", "-f", "make_latest=false"]);
|
|
574
|
+
const separator = response.search(/\r?\n\r?\n/u);
|
|
575
|
+
if (!/^HTTP\/(?:1\.1|2(?:\.0)?) 201(?: [^\r\n]*)?\r?\n/u.test(response) || separator < 0) {
|
|
576
|
+
throw new Error("Draft creation did not return an exact 201 receipt; reconcile provider state before retrying");
|
|
577
|
+
}
|
|
578
|
+
const created = record(JSON.parse(response.slice(separator).trim()) as unknown, "Created draft");
|
|
579
|
+
if (created.draft !== true || verifyProviderRelease(created, manifest, assets, true, changelog).length !== assets.length) {
|
|
580
|
+
throw new Error("Created draft response is not the exact empty draft");
|
|
581
|
+
}
|
|
582
|
+
// The list response may omit a successful creation. Its exact 201
|
|
583
|
+
// response owns the new ID; never rediscover or create again in this run.
|
|
584
|
+
releaseId = positive(created.id, "Created draft ID");
|
|
585
|
+
}
|
|
586
|
+
const releasePath = `/repos/${repository}/releases/${releaseId}`;
|
|
587
|
+
const readExact = (): unknown => {
|
|
588
|
+
const release = record(read(releasePath), "Exact release");
|
|
589
|
+
if (release.id !== releaseId) throw new Error("Release ID changed during publication");
|
|
590
|
+
return release;
|
|
591
|
+
};
|
|
592
|
+
const verifyRemoteBytes = (value: unknown): void => {
|
|
593
|
+
const release = record(value, "Complete release");
|
|
594
|
+
if (!Array.isArray(release.assets) || release.assets.length !== assets.length) throw new Error("Remote asset inventory is incomplete");
|
|
595
|
+
for (const value of release.assets) {
|
|
596
|
+
const asset = record(value, "Remote asset");
|
|
597
|
+
const expected = assets.find((candidate) => candidate.name === asset.name);
|
|
598
|
+
if (expected === undefined || expected.bytes <= 0 || expected.bytes > maximumFileBytes) throw new Error("Remote asset exceeds its admitted byte bound");
|
|
599
|
+
const bytes = download(positive(asset.id, "Remote asset ID"), expected.bytes);
|
|
600
|
+
if (bytes.length !== expected.bytes || hash(bytes) !== expected.sha256) throw new Error("Downloaded release asset differs from the admitted canonical bytes");
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
let release = readExact();
|
|
604
|
+
let missing = verifyProviderRelease(release, manifest, assets, true, changelog);
|
|
605
|
+
for (const name of missing) {
|
|
606
|
+
authorize();
|
|
607
|
+
run("gh", ["api", "--method", "POST", `https://uploads.github.com/repos/${repository}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`,
|
|
608
|
+
"--input", join(directory, name), "-H", "Content-Type: application/octet-stream",
|
|
609
|
+
"-H", `Content-Length: ${assets.find((asset) => asset.name === name)!.bytes}`]);
|
|
610
|
+
release = readExact();
|
|
611
|
+
missing = verifyProviderRelease(release, manifest, assets, true, changelog);
|
|
612
|
+
}
|
|
613
|
+
if (missing.length !== 0) throw new Error("Draft release is missing canonical assets");
|
|
614
|
+
release = readExact();
|
|
615
|
+
if (verifyProviderRelease(release, manifest, assets, true, changelog).length !== 0) throw new Error("Draft release became incomplete before publication");
|
|
616
|
+
if (record(release, "Release").draft === true) {
|
|
617
|
+
verifyRemoteBytes(release);
|
|
618
|
+
authorize();
|
|
619
|
+
run("gh", ["api", "--method", "PATCH", releasePath, "-F", "draft=false", "-f", "make_latest=true"]);
|
|
620
|
+
}
|
|
621
|
+
release = readExact();
|
|
622
|
+
verifyProviderRelease(release, manifest, assets, false, changelog);
|
|
623
|
+
verifyRemoteBytes(release);
|
|
624
|
+
const published = record(read(`/repos/${repository}/releases/tags/${manifest.tag}`), "Published release");
|
|
625
|
+
if (published.id !== releaseId) throw new Error("Published tag resolves to another release ID");
|
|
626
|
+
verifyProviderRelease(published, manifest, assets, false, changelog);
|
|
627
|
+
const latest = record(read(`/repos/${repository}/releases/latest`), "Latest release");
|
|
628
|
+
if (latest.id !== releaseId || latest.tag_name !== manifest.tag) throw new Error("Canonical release is not GitHub Latest");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function publish(directory: string): Promise<void> {
|
|
632
|
+
const manifest = await verifyReleaseFiles(directory);
|
|
633
|
+
if (manifest.sourceSha !== process.env.VERIFIED_SOURCE_SHA || manifest.workflowSha !== process.env.WORKFLOW_SHA
|
|
634
|
+
|| manifest.tag !== process.env.VERIFIED_TAG || String(manifest.runId) !== process.env.GITHUB_RUN_ID
|
|
635
|
+
|| String(manifest.runAttempt) !== process.env.GITHUB_RUN_ATTEMPT) throw new Error("Release handoff differs from the authorized run outputs");
|
|
636
|
+
verifyAttestations(directory, manifest);
|
|
637
|
+
publishVerifiedRelease(directory, manifest, await assetIdentities(directory), taggedChangelog(manifest.sourceSha));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function downloadCanonicalRelease(directory: string, version: string, expectedSourceSha: string | undefined): Promise<ReleaseManifest> {
|
|
641
|
+
const tag = `v${stableVersion(version)}`;
|
|
642
|
+
await mkdir(directory, { recursive: false });
|
|
643
|
+
const expected = [`hraness-wordcell-${version}.tgz`, ...metadataNames, "provenance.jsonl"];
|
|
644
|
+
for (const name of expected) command("gh", ["release", "download", tag, "--repo", repository, "--dir", directory, "--pattern", name]);
|
|
645
|
+
const manifest = await verifyReleaseFiles(directory);
|
|
646
|
+
if (manifest.version !== version || manifest.sourceSha !== expectedSourceSha) throw new Error("Canonical GitHub source differs from the reviewed mirror source");
|
|
647
|
+
verifyAttestations(directory, manifest);
|
|
648
|
+
let changelog: string | undefined;
|
|
649
|
+
try { changelog = taggedChangelog(manifest.sourceSha); } catch (error) {
|
|
650
|
+
// Pre-standard tags may predate CHANGELOG.md; standard tags must carry it.
|
|
651
|
+
if (!isPreStandardRelease(manifest.version)) throw error;
|
|
652
|
+
}
|
|
653
|
+
verifyProviderRelease(api(`/repos/${repository}/releases/tags/${tag}`), manifest, await assetIdentities(directory), false, changelog);
|
|
654
|
+
verifyCanonicalPublication(manifest);
|
|
655
|
+
const ref = record(api(`/repos/${repository}/git/ref/tags/${tag}`), "Canonical tag");
|
|
656
|
+
const object = record(ref.object, "Annotated tag object");
|
|
657
|
+
if (object.type !== "tag" || !exactHex(object.sha, 40)) throw new Error("Canonical release tag is not annotated");
|
|
658
|
+
const tagged = record(api(`/repos/${repository}/git/tags/${object.sha}`), "Annotated tag");
|
|
659
|
+
const source = record(tagged.object, "Tagged source");
|
|
660
|
+
if (source.type !== "commit" || source.sha !== manifest.sourceSha || tagged.tag !== tag) throw new Error("Canonical tag does not bind its attested source");
|
|
661
|
+
return manifest;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Verify an updater's selected bytes with the same release checks as delivery. */
|
|
665
|
+
export async function verifyUpdateArtifact(
|
|
666
|
+
artifact: { path: string; version: string; sha256: string },
|
|
667
|
+
dependencies: {
|
|
668
|
+
read?: (path: string) => unknown;
|
|
669
|
+
download?: (id: number, maximumBytes: number) => Uint8Array;
|
|
670
|
+
attest?: typeof verifyAttestations;
|
|
671
|
+
publication?: typeof verifyCanonicalPublication;
|
|
672
|
+
} = {},
|
|
673
|
+
): Promise<void> {
|
|
674
|
+
const tag = `v${stableVersion(artifact.version)}`;
|
|
675
|
+
const archiveName = `hraness-wordcell-${artifact.version}.tgz`;
|
|
676
|
+
const archive = await boundedFile(artifact.path);
|
|
677
|
+
if (!exactHex(artifact.sha256, 64) || hash(archive) !== artifact.sha256) throw new Error("Selected update archive changed before verification");
|
|
678
|
+
const read = dependencies.read ?? api;
|
|
679
|
+
const ref = record(read(`/repos/${repository}/git/ref/tags/${tag}`), "Update tag");
|
|
680
|
+
const object = record(ref.object, "Update annotated tag");
|
|
681
|
+
if (object.type !== "tag" || !exactHex(object.sha, 40)) throw new Error("Update tag is not annotated");
|
|
682
|
+
const tagged = record(read(`/repos/${repository}/git/tags/${object.sha}`), "Update tag identity");
|
|
683
|
+
const source = record(tagged.object, "Update source");
|
|
684
|
+
if (tagged.tag !== tag || source.type !== "commit" || !exactHex(source.sha, 40)) throw new Error("Update tag source is invalid");
|
|
685
|
+
const release = record(read(`/repos/${repository}/releases/tags/${tag}`), "Update release");
|
|
686
|
+
if (release.tag_name !== tag || release.immutable !== true || release.draft !== false || release.prerelease !== false
|
|
687
|
+
|| !Array.isArray(release.assets) || release.assets.length !== 5) throw new Error("Update needs the complete immutable stable release");
|
|
688
|
+
const directory = await mkdtemp(join(tmpdir(), "wordcell-update-verify-"));
|
|
689
|
+
try {
|
|
690
|
+
await writeFile(join(directory, archiveName), archive, { flag: "wx", mode: 0o600 });
|
|
691
|
+
for (const name of [...metadataNames, "provenance.jsonl"]) {
|
|
692
|
+
const matches = release.assets.filter((item: unknown) => record(item, "Update asset").name === name);
|
|
693
|
+
if (matches.length !== 1) throw new Error("Update metadata is missing or duplicated");
|
|
694
|
+
const asset = record(matches[0], "Update asset");
|
|
695
|
+
const id = positive(asset.id, "Update asset ID");
|
|
696
|
+
const size = positive(asset.size, "Update asset size");
|
|
697
|
+
if (size > 4 * 1024 * 1024 || asset.url !== `https://api.github.com/repos/${repository}/releases/assets/${id}`
|
|
698
|
+
|| !exactAssetBrowserUrl(asset.browser_download_url, name, tag, false)) throw new Error("Update metadata exceeds its limit or has a foreign URL");
|
|
699
|
+
const bytes = (dependencies.download ?? binaryAsset)(id, size);
|
|
700
|
+
if (bytes.length !== size || asset.digest !== `sha256:${hash(bytes)}`) throw new Error("Update metadata digest differs");
|
|
701
|
+
await writeFile(join(directory, name), bytes, { flag: "wx", mode: 0o600 });
|
|
702
|
+
}
|
|
703
|
+
const manifest = await verifyReleaseFiles(directory);
|
|
704
|
+
if (manifest.version !== artifact.version || manifest.sourceSha !== source.sha || manifest.archive.sha256 !== artifact.sha256) {
|
|
705
|
+
throw new Error("Update manifest does not bind the selected archive and tag");
|
|
706
|
+
}
|
|
707
|
+
const content = record(read(`/repos/${repository}/contents/CHANGELOG.md?ref=${manifest.sourceSha}`), "Tagged changelog");
|
|
708
|
+
if (content.type !== "file" || content.encoding !== "base64" || typeof content.content !== "string"
|
|
709
|
+
|| content.content.length > maximumChangelogBytes * 2) throw new Error("Tagged changelog is invalid");
|
|
710
|
+
const bytes = Buffer.from(content.content, "base64");
|
|
711
|
+
if (bytes.length > maximumChangelogBytes || content.size !== bytes.length) throw new Error("Tagged changelog exceeds its size limit");
|
|
712
|
+
verifyProviderRelease(release, manifest, await assetIdentities(directory), false, bytes.toString("utf8"));
|
|
713
|
+
(dependencies.attest ?? verifyAttestations)(directory, manifest);
|
|
714
|
+
(dependencies.publication ?? verifyCanonicalPublication)(manifest, read);
|
|
715
|
+
// Recheck the provider identity after the potentially longer signature/run checks.
|
|
716
|
+
const current = record(read(`/repos/${repository}/releases/tags/${tag}`), "Verified update release");
|
|
717
|
+
if (current.id !== release.id) throw new Error("Update release identity changed during verification");
|
|
718
|
+
verifyProviderRelease(current, manifest, await assetIdentities(directory), false, bytes.toString("utf8"));
|
|
719
|
+
} finally {
|
|
720
|
+
await rm(directory, { recursive: true, force: true });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export async function runGitHubReleaseCommand(arguments_: readonly string[]): Promise<void> {
|
|
725
|
+
const [mode, directory, version] = arguments_;
|
|
726
|
+
if (directory === undefined) throw new Error("Usage: node scripts/github-release.ts prepare|verify|publish|body|download <directory> [version|changelog-commit]");
|
|
727
|
+
if (mode === "body") {
|
|
728
|
+
// Print the release body for a downloaded release-manifest.json. The
|
|
729
|
+
// changelog comes from the tagged commit unless a full commit is named.
|
|
730
|
+
const manifest = parseReleaseManifest(JSON.parse((await boundedFile(join(resolve(directory), "release-manifest.json"), 16_384)).toString("utf8")) as unknown);
|
|
731
|
+
process.stdout.write(releaseBody(manifest, taggedChangelog(version ?? manifest.sourceSha)));
|
|
732
|
+
} else if (mode === "prepare" && version === undefined) await prepare(resolve(directory));
|
|
733
|
+
else if (mode === "verify" && version === undefined) { const manifest = await verifyReleaseFiles(resolve(directory)); verifyAttestations(resolve(directory), manifest); }
|
|
734
|
+
else if (mode === "publish" && version === undefined) await publish(resolve(directory));
|
|
735
|
+
else if (mode === "download" && version !== undefined) await downloadCanonicalRelease(resolve(directory), version, process.env.VERIFIED_SOURCE_SHA);
|
|
736
|
+
else throw new Error("Unsupported GitHub release command");
|
|
737
|
+
}
|