@kungfu-tech/buildchain 2.14.18-alpha.1 → 2.14.18-alpha.11
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 +1 -0
- package/actions/promote-buildchain-ref/README.md +33 -30
- package/bin/buildchain.mjs +16 -4
- package/dist/site/buildchain-contract.json +86 -31
- package/dist/site/buildchain-site.json +88 -27
- package/dist/site/capability-registry.json +6 -5
- package/dist/site/cli-registry.json +12 -0
- package/dist/site/controller-registry.json +50 -6
- package/dist/site/kfd-claims.json +110 -21
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +21 -6
- package/dist/site/node-api-registry.json +19 -6
- package/dist/site/page-registry.json +70 -17
- package/dist/site/public-surface-audit.json +130 -23
- package/dist/site/publication-authority-registry.json +21 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +1 -0
- package/dist/site/site-manifest.json +18 -10
- package/dist/site/workflow-registry.json +62 -16
- package/docs/MAP.md +3 -1
- package/docs/github-governance-authority.md +267 -0
- package/docs/lifecycle-protocol.md +10 -10
- package/docs/publish-transaction.md +73 -4
- package/docs/release-governance.md +38 -24
- package/docs/reusable-build-surface.md +29 -11
- package/docs/web-surface-deployments.md +46 -0
- package/package.json +2 -1
- package/packages/core/buildchain-kfd-claims.js +2 -0
- package/packages/core/buildchain-publication-authority.js +1 -0
- package/packages/core/controller-evidence.js +1 -1
- package/packages/core/github-governance-authority.js +1312 -0
- package/packages/core/index.js +22 -0
- package/packages/core/publication-control-plane-audit.js +4 -1
- package/scripts/audit-github-governance.mjs +474 -0
- package/scripts/audit-publication-control-plane.mjs +41 -5
- package/scripts/check-inventory.mjs +8 -0
- package/scripts/generate-channel-promotion-workflow.mjs +3 -0
- package/scripts/generate-site-bundle.mjs +5 -0
- package/scripts/installer-publication.mjs +351 -0
- package/scripts/publication-commit-evidence.mjs +158 -0
- package/scripts/reconcile-github-governance.mjs +549 -0
- package/scripts/release-candidate-resolver.mjs +53 -0
- package/scripts/web-surface-core.mjs +127 -14
|
@@ -325,6 +325,7 @@ const manualMetaById = new Map(Object.entries({
|
|
|
325
325
|
cli: { capabilityGroup: "api-cli-reference", audience: ["agent", "developer"], maturity: "stable", order: 40 },
|
|
326
326
|
"release-passport": { capabilityGroup: "release-passport-trust", audience: ["release-operator", "agent"], maturity: "stable", order: 100 },
|
|
327
327
|
"publication-authority": { capabilityGroup: "release-passport-trust", audience: ["release-operator", "agent"], maturity: "preview", order: 105 },
|
|
328
|
+
"github-governance-authority": { capabilityGroup: "governance-versioning", audience: ["maintainer", "release-operator", "agent"], maturity: "preview", order: 106 },
|
|
328
329
|
"controller-evidence": { capabilityGroup: "reusable-build", audience: ["consumer", "release-operator", "agent"], maturity: "draft", order: 205 },
|
|
329
330
|
"binary-distribution": { capabilityGroup: "release-passport-trust", audience: ["release-operator", "agent"], maturity: "stable", order: 110 },
|
|
330
331
|
"publish-transaction": { capabilityGroup: "release-passport-trust", audience: ["release-operator"], maturity: "stable", order: 120 },
|
|
@@ -385,6 +386,7 @@ function cliCommandMeta(id) {
|
|
|
385
386
|
const map = new Map(Object.entries({
|
|
386
387
|
audit: { group: "release-passport-trust", purpose: "Inspect read-only publication authority audit commands." },
|
|
387
388
|
"audit-publication-control-plane": { group: "release-passport-trust", purpose: "Read GitHub publication controls, bind provider-enforced npm identity, and emit a sanitized expiring receipt." },
|
|
389
|
+
"audit-github-governance": { group: "governance-versioning", purpose: "Read the managed GitHub zone, aggregate native protection and ownership, and emit sanitized fail-closed governance receipts." },
|
|
388
390
|
badges: { group: "distribution-indexes", purpose: "Inspect README badge command families." },
|
|
389
391
|
"badges-bundle": { group: "distribution-indexes", purpose: "Generate or verify the combined KFD and Release Passport badge bundle." },
|
|
390
392
|
"badges-readme": { group: "distribution-indexes", purpose: "Generate or verify managed README badge blocks." },
|
|
@@ -408,6 +410,7 @@ function cliCommandMeta(id) {
|
|
|
408
410
|
homebrew: { group: "distribution-indexes", purpose: "Inspect Homebrew distribution-index command families." },
|
|
409
411
|
"homebrew-check": { group: "distribution-indexes", purpose: "Verify Homebrew tap metadata against upstream release passport evidence." },
|
|
410
412
|
"homebrew-update-formula": { group: "distribution-indexes", purpose: "Generate Homebrew Formula metadata from upstream release passport evidence." },
|
|
413
|
+
"github-governance": { group: "governance-versioning", purpose: "Plan, apply, or roll back one root-bound GitHub branch-protection rollout after a frozen read-only inventory." },
|
|
411
414
|
"infra-contract": { group: "governance-versioning", purpose: "Validate and publish provider-neutral infrastructure contract evidence." },
|
|
412
415
|
init: { group: "getting-started", purpose: "Bootstrap a repository with Buildchain configuration and caller workflow files." },
|
|
413
416
|
inspect: { group: "release-passport-trust", purpose: "Inspect release and artifact evidence command families." },
|
|
@@ -506,6 +509,7 @@ function nodeApiMeta(exportName) {
|
|
|
506
509
|
"./publication-authority": { group: "release-passport-trust", summary: "Sealed publication authority registry, runner provenance, control-plane audit, admission, and independent verification APIs." },
|
|
507
510
|
"./publication-control-plane-audit": { group: "release-passport-trust", summary: "Read-only publication control-plane snapshot evaluation APIs." },
|
|
508
511
|
"./buildchain-publication-authority": { group: "release-passport-trust", summary: "Buildchain-owned closed-world publication authority descriptor registry." },
|
|
512
|
+
"./github-governance-authority": { group: "governance-versioning", summary: "Fail-closed GitHub ownership, effective-policy, managed-zone admission, rollout-plan, and immutable receipt APIs." },
|
|
509
513
|
"./artifact-passport": { group: "release-passport-trust", summary: "Artifact passport digest and evidence helper APIs." },
|
|
510
514
|
"./artifact-verification-envelope": { group: "release-passport-trust", summary: "Sealed exact-root, lifecycle, identity, and existing KFD assessment inputs for KFX admission." },
|
|
511
515
|
"./anchored-version-material": { group: "reusable-build", summary: "Anchored/manual derived version material preflight, exact-tree binding, and digest evidence APIs." },
|
|
@@ -870,6 +874,7 @@ function buildSiteBundle() {
|
|
|
870
874
|
["paper-release", "reusable-build"],
|
|
871
875
|
["release-propagation", "release-propagation"],
|
|
872
876
|
["dev-pr-auto-merge", "dev-governance"],
|
|
877
|
+
["github-governance-audit", "dev-governance"],
|
|
873
878
|
["binary-distribution", "release-passport"],
|
|
874
879
|
["buildchain-patrol", "repository-patrol"],
|
|
875
880
|
["buildchain-patrol-daily", "repository-patrol"],
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
|
|
9
|
+
export const INSTALLER_PUBLICATION_SCHEMA =
|
|
10
|
+
"kungfu.bootstrap-installer-publication/v1";
|
|
11
|
+
export const INSTALLER_EVIDENCE_SCHEMA =
|
|
12
|
+
"kungfu-buildchain-installer-publication-evidence/v1";
|
|
13
|
+
|
|
14
|
+
function canonical(value) {
|
|
15
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
16
|
+
if (value && typeof value === "object") {
|
|
17
|
+
return Object.fromEntries(
|
|
18
|
+
Object.entries(value)
|
|
19
|
+
.filter(([, item]) => item !== undefined)
|
|
20
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
21
|
+
.map(([key, item]) => [key, canonical(item)]),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function root(value) {
|
|
28
|
+
return `sha256:${createHash("sha256")
|
|
29
|
+
.update(JSON.stringify(canonical(value)))
|
|
30
|
+
.digest("hex")}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sha256(bytes) {
|
|
34
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function requireRoot(value, label) {
|
|
38
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(value || "")) {
|
|
39
|
+
throw new Error(`${label} must be a sha256 root`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function publicHttpsUrl(value, label) {
|
|
44
|
+
let url;
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(value);
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(`${label} must be public HTTPS`);
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
url.protocol !== "https:" ||
|
|
52
|
+
url.username ||
|
|
53
|
+
url.password ||
|
|
54
|
+
url.search ||
|
|
55
|
+
url.hash
|
|
56
|
+
) {
|
|
57
|
+
throw new Error(`${label} must be public HTTPS`);
|
|
58
|
+
}
|
|
59
|
+
return url;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeRelative(value, label) {
|
|
63
|
+
const normalized = String(value || "")
|
|
64
|
+
.replaceAll("\\", "/")
|
|
65
|
+
.replace(/^\/+/, "");
|
|
66
|
+
if (
|
|
67
|
+
!normalized ||
|
|
68
|
+
normalized.split("/").includes("..") ||
|
|
69
|
+
path.isAbsolute(normalized)
|
|
70
|
+
) {
|
|
71
|
+
throw new Error(`${label} is not a safe relative path`);
|
|
72
|
+
}
|
|
73
|
+
return normalized;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readAsset(artifactRoot, relativePath, expected) {
|
|
77
|
+
const safePath = safeRelative(relativePath, "installer asset path");
|
|
78
|
+
const absolute = path.resolve(artifactRoot, safePath);
|
|
79
|
+
const rootPath = path.resolve(artifactRoot);
|
|
80
|
+
if (
|
|
81
|
+
!absolute.startsWith(`${rootPath}${path.sep}`) ||
|
|
82
|
+
!fs.statSync(absolute).isFile()
|
|
83
|
+
) {
|
|
84
|
+
throw new Error(`installer asset is missing: ${safePath}`);
|
|
85
|
+
}
|
|
86
|
+
const bytes = fs.readFileSync(absolute);
|
|
87
|
+
const observed = {
|
|
88
|
+
path: safePath,
|
|
89
|
+
size: bytes.length,
|
|
90
|
+
digest: sha256(bytes),
|
|
91
|
+
};
|
|
92
|
+
if (observed.size !== expected.size || observed.digest !== expected.digest) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`installer asset differs from publication metadata: ${safePath}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return observed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validateInstallerPublication({ publication, artifactRoot }) {
|
|
101
|
+
if (publication?.schema !== INSTALLER_PUBLICATION_SCHEMA) {
|
|
102
|
+
throw new Error("unsupported installer publication schema");
|
|
103
|
+
}
|
|
104
|
+
if (!["alpha", "stable"].includes(publication.channel)) {
|
|
105
|
+
throw new Error("installer publication channel is invalid");
|
|
106
|
+
}
|
|
107
|
+
requireRoot(publication.channelPayloadRoot, "channelPayloadRoot");
|
|
108
|
+
requireRoot(publication.channelFileDigest, "channelFileDigest");
|
|
109
|
+
requireRoot(publication.releasePassport?.root, "releasePassport.root");
|
|
110
|
+
publicHttpsUrl(publication.channelUrl, "channelUrl");
|
|
111
|
+
if (!/^[a-f0-9]{40}$/.test(publication.sourceCommit || "")) {
|
|
112
|
+
throw new Error("installer publication sourceCommit is invalid");
|
|
113
|
+
}
|
|
114
|
+
const immutablePath = safeRelative(
|
|
115
|
+
publication.immutablePath,
|
|
116
|
+
"immutablePath",
|
|
117
|
+
);
|
|
118
|
+
if (publication.installerVersion !== "v1") {
|
|
119
|
+
throw new Error("installer publication version is unsupported");
|
|
120
|
+
}
|
|
121
|
+
if (!immutablePath.startsWith("installers/")) {
|
|
122
|
+
throw new Error("installer immutablePath must stay below installers/");
|
|
123
|
+
}
|
|
124
|
+
if (!publication.releasePassport?.ref) {
|
|
125
|
+
throw new Error("installer publication releasePassport.ref is required");
|
|
126
|
+
}
|
|
127
|
+
if (!Array.isArray(publication.entries) || publication.entries.length === 0) {
|
|
128
|
+
throw new Error("installer publication has no platform entries");
|
|
129
|
+
}
|
|
130
|
+
const identities = new Set();
|
|
131
|
+
for (const entry of publication.entries) {
|
|
132
|
+
const identity = `${entry.platform}/${entry.architecture}`;
|
|
133
|
+
if (identities.has(identity))
|
|
134
|
+
throw new Error(`duplicate installer target: ${identity}`);
|
|
135
|
+
identities.add(identity);
|
|
136
|
+
requireRoot(entry.manifestRoot, `${identity}.manifestRoot`);
|
|
137
|
+
requireRoot(entry.artifactRoot, `${identity}.artifactRoot`);
|
|
138
|
+
requireRoot(entry.artifactDigest, `${identity}.artifactDigest`);
|
|
139
|
+
publicHttpsUrl(entry.artifactUrl, `${identity}.artifactUrl`);
|
|
140
|
+
if (
|
|
141
|
+
entry.sourceCommit !== publication.sourceCommit ||
|
|
142
|
+
!entry.version ||
|
|
143
|
+
!Number.isSafeInteger(entry.artifactSize) ||
|
|
144
|
+
entry.artifactSize < 1 ||
|
|
145
|
+
!entry.artifactSignature
|
|
146
|
+
) {
|
|
147
|
+
throw new Error(`installer target source mismatch: ${identity}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!Array.isArray(publication.assets) || publication.assets.length !== 2) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
"installer publication must bind install.sh and install.ps1",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const expectedNames = new Set(["install.sh", "install.ps1"]);
|
|
156
|
+
const assets = publication.assets
|
|
157
|
+
.map((asset) => {
|
|
158
|
+
if (!expectedNames.delete(asset.name)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`unexpected or duplicate installer asset: ${asset.name}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
requireRoot(asset.digest, `${asset.name}.digest`);
|
|
164
|
+
if (!Number.isSafeInteger(asset.size) || asset.size < 1) {
|
|
165
|
+
throw new Error(`${asset.name}.size is invalid`);
|
|
166
|
+
}
|
|
167
|
+
const immutable = readAsset(
|
|
168
|
+
artifactRoot,
|
|
169
|
+
`${immutablePath}/${asset.name}`,
|
|
170
|
+
asset,
|
|
171
|
+
);
|
|
172
|
+
const friendly = readAsset(artifactRoot, asset.name, asset);
|
|
173
|
+
const friendlyUrl = publicHttpsUrl(
|
|
174
|
+
asset.friendlyUrl,
|
|
175
|
+
`${asset.name}.friendlyUrl`,
|
|
176
|
+
);
|
|
177
|
+
const immutableUrl = publicHttpsUrl(
|
|
178
|
+
asset.immutableUrl,
|
|
179
|
+
`${asset.name}.immutableUrl`,
|
|
180
|
+
);
|
|
181
|
+
if (
|
|
182
|
+
friendlyUrl.origin !== immutableUrl.origin ||
|
|
183
|
+
friendlyUrl.pathname !== `/${asset.name}` ||
|
|
184
|
+
immutableUrl.pathname !== `/${immutablePath}/${asset.name}`
|
|
185
|
+
) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`installer asset URL mapping is invalid: ${asset.name}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
name: asset.name,
|
|
192
|
+
contentType: asset.contentType,
|
|
193
|
+
size: asset.size,
|
|
194
|
+
digest: asset.digest,
|
|
195
|
+
immutable,
|
|
196
|
+
friendly,
|
|
197
|
+
immutableUrl: immutableUrl.href,
|
|
198
|
+
friendlyUrl: friendlyUrl.href,
|
|
199
|
+
};
|
|
200
|
+
})
|
|
201
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
202
|
+
const evidence = {
|
|
203
|
+
schema: INSTALLER_EVIDENCE_SCHEMA,
|
|
204
|
+
state: "verified",
|
|
205
|
+
installerVersion: publication.installerVersion,
|
|
206
|
+
channel: publication.channel,
|
|
207
|
+
sourceCommit: publication.sourceCommit,
|
|
208
|
+
channelPayloadRoot: publication.channelPayloadRoot,
|
|
209
|
+
channelFileDigest: publication.channelFileDigest,
|
|
210
|
+
releasePassport: publication.releasePassport,
|
|
211
|
+
immutablePath,
|
|
212
|
+
entries: publication.entries,
|
|
213
|
+
assets,
|
|
214
|
+
};
|
|
215
|
+
return { ...evidence, evidenceRoot: root(evidence) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function cacheMaxAge(value) {
|
|
219
|
+
const match = /(?:^|,)\s*max-age=([0-9]+)(?:,|$)/i.exec(value || "");
|
|
220
|
+
return match ? Number(match[1]) : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function readPublicAsset(url, fetchImpl) {
|
|
224
|
+
const response = await fetchImpl(url, {
|
|
225
|
+
redirect: "manual",
|
|
226
|
+
headers: { accept: "text/plain, application/octet-stream;q=0.9" },
|
|
227
|
+
});
|
|
228
|
+
if (response.status !== 200) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`installer public read-back failed (${response.status}): ${url}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
url,
|
|
235
|
+
bytes: Buffer.from(await response.arrayBuffer()),
|
|
236
|
+
contentType: response.headers.get("content-type") || "",
|
|
237
|
+
cacheControl: response.headers.get("cache-control") || "",
|
|
238
|
+
etag: response.headers.get("etag") || "",
|
|
239
|
+
versionId: response.headers.get("x-amz-version-id") || "",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function verifyInstallerPublicReadback({
|
|
244
|
+
publication,
|
|
245
|
+
fetchImpl = globalThis.fetch,
|
|
246
|
+
}) {
|
|
247
|
+
if (typeof fetchImpl !== "function")
|
|
248
|
+
throw new Error("fetch implementation is required");
|
|
249
|
+
const observations = [];
|
|
250
|
+
for (const asset of publication.assets || []) {
|
|
251
|
+
for (const [route, url] of [
|
|
252
|
+
["friendly", asset.friendlyUrl],
|
|
253
|
+
["immutable", asset.immutableUrl],
|
|
254
|
+
]) {
|
|
255
|
+
const observed = await readPublicAsset(url, fetchImpl);
|
|
256
|
+
const digest = sha256(observed.bytes);
|
|
257
|
+
if (observed.bytes.length !== asset.size || digest !== asset.digest) {
|
|
258
|
+
throw new Error(`installer public bytes drifted: ${url}`);
|
|
259
|
+
}
|
|
260
|
+
const contentType = observed.contentType.toLowerCase();
|
|
261
|
+
if (
|
|
262
|
+
!contentType.startsWith("text/plain") &&
|
|
263
|
+
!contentType.startsWith("text/x-shellscript") &&
|
|
264
|
+
!contentType.startsWith("application/octet-stream")
|
|
265
|
+
) {
|
|
266
|
+
throw new Error(`installer public content type is unsafe: ${url}`);
|
|
267
|
+
}
|
|
268
|
+
const maxAge = cacheMaxAge(observed.cacheControl);
|
|
269
|
+
if (
|
|
270
|
+
route === "friendly" &&
|
|
271
|
+
(maxAge === null ||
|
|
272
|
+
maxAge > 300 ||
|
|
273
|
+
!/must-revalidate/i.test(observed.cacheControl))
|
|
274
|
+
) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`friendly installer cache policy is not revalidated: ${url}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (
|
|
280
|
+
route === "immutable" &&
|
|
281
|
+
(maxAge === null ||
|
|
282
|
+
maxAge < 31_536_000 ||
|
|
283
|
+
!/immutable/i.test(observed.cacheControl))
|
|
284
|
+
) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`immutable installer cache policy is not immutable: ${url}`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
observations.push({
|
|
290
|
+
name: asset.name,
|
|
291
|
+
route,
|
|
292
|
+
url,
|
|
293
|
+
size: observed.bytes.length,
|
|
294
|
+
digest,
|
|
295
|
+
contentType: observed.contentType,
|
|
296
|
+
cacheControl: observed.cacheControl,
|
|
297
|
+
etag: observed.etag,
|
|
298
|
+
versionId: observed.versionId,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const evidence = {
|
|
303
|
+
schema: INSTALLER_EVIDENCE_SCHEMA,
|
|
304
|
+
state: "public-readback-verified",
|
|
305
|
+
sourceCommit: publication.sourceCommit,
|
|
306
|
+
channelPayloadRoot: publication.channelPayloadRoot,
|
|
307
|
+
releasePassport: publication.releasePassport,
|
|
308
|
+
observations,
|
|
309
|
+
};
|
|
310
|
+
return { ...evidence, evidenceRoot: root(evidence) };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function parseArgs(args) {
|
|
314
|
+
const options = {};
|
|
315
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
316
|
+
const value = args[index];
|
|
317
|
+
if (value === "--manifest") options.manifest = args[++index];
|
|
318
|
+
else if (value === "--artifact-root") options.artifactRoot = args[++index];
|
|
319
|
+
else if (value === "--public-readback") options.publicReadback = true;
|
|
320
|
+
else throw new Error(`unknown argument: ${value}`);
|
|
321
|
+
}
|
|
322
|
+
if (!options.manifest) throw new Error("--manifest is required");
|
|
323
|
+
if (!options.publicReadback && !options.artifactRoot) {
|
|
324
|
+
throw new Error("--artifact-root is required without --public-readback");
|
|
325
|
+
}
|
|
326
|
+
return options;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function main(args) {
|
|
330
|
+
const options = parseArgs(args);
|
|
331
|
+
const publication = JSON.parse(
|
|
332
|
+
fs.readFileSync(path.resolve(options.manifest), "utf8"),
|
|
333
|
+
);
|
|
334
|
+
const evidence = options.publicReadback
|
|
335
|
+
? await verifyInstallerPublicReadback({ publication })
|
|
336
|
+
: validateInstallerPublication({
|
|
337
|
+
publication,
|
|
338
|
+
artifactRoot: path.resolve(options.artifactRoot),
|
|
339
|
+
});
|
|
340
|
+
process.stdout.write(`${JSON.stringify(evidence, null, 2)}\n`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (
|
|
344
|
+
process.argv[1] &&
|
|
345
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
346
|
+
) {
|
|
347
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
348
|
+
process.stderr.write(`installer-publication: ${error.message}\n`);
|
|
349
|
+
process.exitCode = 1;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
const SCHEMA = "kungfu-buildchain-publication-commit-evidence/v1";
|
|
8
|
+
|
|
9
|
+
function requiredString(value, label) {
|
|
10
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
11
|
+
throw new Error(`${label} is required`);
|
|
12
|
+
}
|
|
13
|
+
return value.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sha256Root(value, label) {
|
|
17
|
+
const normalized = requiredString(value, label);
|
|
18
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(normalized)) {
|
|
19
|
+
throw new Error(`${label} must be a lowercase sha256 root`);
|
|
20
|
+
}
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function exactSha(value, label) {
|
|
25
|
+
const normalized = requiredString(value, label);
|
|
26
|
+
if (!/^[0-9a-f]{40}$/.test(normalized)) {
|
|
27
|
+
throw new Error(`${label} must be an exact Git SHA`);
|
|
28
|
+
}
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function publicHttps(value, label) {
|
|
33
|
+
const normalized = requiredString(value, label);
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(normalized);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(`${label} must be a public HTTPS URL`);
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
url.protocol !== "https:" ||
|
|
42
|
+
!url.hostname ||
|
|
43
|
+
url.username ||
|
|
44
|
+
url.password ||
|
|
45
|
+
url.search ||
|
|
46
|
+
url.hash
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`${label} must be a public HTTPS URL without credentials, query, or fragment`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return normalized;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function validatePublicationCommitEvidence(
|
|
56
|
+
evidence,
|
|
57
|
+
{
|
|
58
|
+
version,
|
|
59
|
+
sourceSha,
|
|
60
|
+
releaseSha,
|
|
61
|
+
releaseTag,
|
|
62
|
+
} = {},
|
|
63
|
+
) {
|
|
64
|
+
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
65
|
+
throw new Error("publication commit evidence must be an object");
|
|
66
|
+
}
|
|
67
|
+
if (evidence.schema !== SCHEMA) {
|
|
68
|
+
throw new Error(`publication commit evidence schema must be ${SCHEMA}`);
|
|
69
|
+
}
|
|
70
|
+
if (evidence.status !== "passed") {
|
|
71
|
+
throw new Error("publication commit evidence status must be passed");
|
|
72
|
+
}
|
|
73
|
+
const identity = evidence.identity || {};
|
|
74
|
+
const expected = {
|
|
75
|
+
version: requiredString(version, "expected version"),
|
|
76
|
+
sourceSha: exactSha(sourceSha, "expected sourceSha"),
|
|
77
|
+
releaseSha: exactSha(releaseSha, "expected releaseSha"),
|
|
78
|
+
releaseTag: requiredString(releaseTag, "expected releaseTag"),
|
|
79
|
+
};
|
|
80
|
+
for (const [field, value] of Object.entries(expected)) {
|
|
81
|
+
if (identity[field] !== value) {
|
|
82
|
+
throw new Error(`publication commit evidence ${field} mismatch`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const publicUrl = publicHttps(evidence.publication?.url, "publication.url");
|
|
86
|
+
const payloadRoot = sha256Root(
|
|
87
|
+
evidence.publication?.payloadRoot,
|
|
88
|
+
"publication.payloadRoot",
|
|
89
|
+
);
|
|
90
|
+
if (
|
|
91
|
+
evidence.readback?.status !== "passed" ||
|
|
92
|
+
publicHttps(evidence.readback?.url, "readback.url") !== publicUrl ||
|
|
93
|
+
sha256Root(evidence.readback?.payloadRoot, "readback.payloadRoot") !==
|
|
94
|
+
payloadRoot
|
|
95
|
+
) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
"publication read-back must pass at the canonical URL with the exact payload root",
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const previousAuthority = evidence.recovery?.previousAuthority;
|
|
101
|
+
const rollbackReference = requiredString(
|
|
102
|
+
evidence.recovery?.rollbackReference,
|
|
103
|
+
"recovery.rollbackReference",
|
|
104
|
+
);
|
|
105
|
+
if (!["preserved", "none"].includes(previousAuthority)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"publication recovery must preserve or explicitly declare no previous authority",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
schema: SCHEMA,
|
|
112
|
+
status: "passed",
|
|
113
|
+
publicUrl,
|
|
114
|
+
payloadRoot,
|
|
115
|
+
identity: expected,
|
|
116
|
+
recovery: {
|
|
117
|
+
previousAuthority,
|
|
118
|
+
rollbackReference,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function main(args) {
|
|
124
|
+
const options = {};
|
|
125
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
126
|
+
const value = args[index];
|
|
127
|
+
if (value === "--evidence") options.evidence = args[++index];
|
|
128
|
+
else if (value === "--version") options.version = args[++index];
|
|
129
|
+
else if (value === "--source-sha") options.sourceSha = args[++index];
|
|
130
|
+
else if (value === "--release-sha") options.releaseSha = args[++index];
|
|
131
|
+
else if (value === "--release-tag") options.releaseTag = args[++index];
|
|
132
|
+
else throw new Error(`unknown argument: ${value}`);
|
|
133
|
+
}
|
|
134
|
+
const evidencePath = path.resolve(
|
|
135
|
+
requiredString(options.evidence, "--evidence"),
|
|
136
|
+
);
|
|
137
|
+
const result = validatePublicationCommitEvidence(
|
|
138
|
+
JSON.parse(fs.readFileSync(evidencePath, "utf8")),
|
|
139
|
+
options,
|
|
140
|
+
);
|
|
141
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
process.argv[1] &&
|
|
146
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
|
|
147
|
+
) {
|
|
148
|
+
try {
|
|
149
|
+
main(process.argv.slice(2));
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error(
|
|
152
|
+
`publication commit evidence failed: ${
|
|
153
|
+
error instanceof Error ? error.message : String(error)
|
|
154
|
+
}`,
|
|
155
|
+
);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
}
|