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