@kungfu-tech/buildchain 3.0.2-alpha.0 → 3.0.2-alpha.10
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 +4 -2
- package/actions/github-artifact-attestation/README.md +10 -0
- package/actions/promote-buildchain-ref/README.md +7 -0
- package/bin/buildchain.mjs +5 -0
- package/bin/internal/trust-release-cli.mjs +74 -3
- package/contracts/auditable-demo-media-profiles-v1.json +168 -0
- package/contracts/evidence/auditable-demo-web-delivery-v1.json +103 -0
- package/contracts/fixtures/auditable-demo-web-delivery-v1/complete-transcript.txt +2 -0
- package/contracts/fixtures/auditable-demo-web-delivery-v1/public-projection.json +16 -0
- package/contracts/fixtures/auditable-demo-web-delivery-v1/scene.json +12 -0
- package/dist/site/artifact-schemas.json +5 -1
- package/dist/site/buildchain-contract.json +133 -34
- package/dist/site/buildchain-site.json +159 -40
- package/dist/site/capability-registry.json +14 -13
- package/dist/site/cli-registry.json +12 -0
- package/dist/site/controller-registry.json +72 -4
- package/dist/site/kfd-claims.json +338 -20
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +24 -9
- package/dist/site/node-api-registry.json +89 -11
- package/dist/site/page-registry.json +135 -25
- package/dist/site/public-surface-audit.json +203 -21
- package/dist/site/publication-authority-registry.json +72 -2
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-model.json +2 -1
- package/dist/site/release-passport-check-manifest.json +1 -0
- package/dist/site/release-provenance.json +6 -0
- package/dist/site/schemas/release-passport-v1.schema.json +6 -0
- package/dist/site/site-manifest.json +21 -13
- package/dist/site/workflow-registry.json +167 -13
- package/docs/MAP.md +5 -1
- package/docs/auditable-demo.md +55 -3
- package/docs/binary-distribution.md +7 -0
- package/docs/cli.md +9 -0
- package/docs/dev-alpha-candidate-patrol.md +111 -0
- package/docs/github-artifact-attestation.md +219 -0
- package/docs/release-governance.md +32 -0
- package/docs/release-passport.md +13 -0
- package/docs/reusable-build-surface.md +83 -61
- package/docs/runtime-train-validation.md +21 -0
- package/docs/versioning.md +1 -0
- package/package.json +8 -1
- package/packages/core/artifact-signing-result.js +228 -0
- package/packages/core/artifact-signing.js +412 -0
- package/packages/core/buildchain-config.js +58 -0
- package/packages/core/buildchain-contract.js +14 -0
- package/packages/core/buildchain-kfd-claims.js +5 -0
- package/packages/core/buildchain-publication-authority.js +3 -0
- package/packages/core/cache-evidence.js +288 -0
- package/packages/core/channel-candidate.js +186 -0
- package/packages/core/detached-artifact-signature.js +121 -0
- package/packages/core/diagnostics.js +276 -10
- package/packages/core/github-artifact-attestation.js +642 -0
- package/packages/core/github-governance-authority.js +77 -16
- package/packages/core/index.js +62 -0
- package/packages/core/publication-authority.js +1 -1
- package/packages/core/release-passport-contract.js +2 -0
- package/packages/core/release-passport.js +60 -3
- package/scripts/auditable-demo.mjs +520 -28
- package/scripts/build-contract-core.mjs +31 -0
- package/scripts/buildchain-channel-router.mjs +8 -2
- package/scripts/check-inventory.mjs +9 -0
- package/scripts/create-github-artifact-attestation-policy.mjs +62 -0
- package/scripts/dev-alpha-candidate-patrol.mjs +864 -0
- package/scripts/dispatch-artifact-signing-authority.mjs +152 -0
- package/scripts/finalize-native-artifact-signing-result.mjs +96 -0
- package/scripts/generate-channel-promotion-workflow.mjs +6 -0
- package/scripts/generate-site-bundle.mjs +20 -0
- package/scripts/import-artifact-signing-results.mjs +76 -0
- package/scripts/inspect-artifact-signing-requests.mjs +101 -0
- package/scripts/locked-source-checkout.mjs +48 -0
- package/scripts/materialize-artifact-signing-request.mjs +66 -0
- package/scripts/merge-artifact-signing-results.mjs +76 -0
- package/scripts/publish-github-artifact-attestation-evidence.mjs +201 -0
- package/scripts/reconcile-github-governance.mjs +158 -25
- package/scripts/release-candidate-resolver.mjs +12 -0
- package/scripts/release-line-policy.mjs +27 -0
- package/scripts/runtime-ref-core.mjs +10 -6
- package/scripts/seal-artifact-signing-requests.mjs +368 -0
- package/scripts/shifu-gate-profile.mjs +26 -20
- package/scripts/sign-detached-artifact-requests.mjs +237 -0
- package/scripts/stage-github-artifact-attestation-inputs.mjs +65 -0
- package/scripts/verify-artifact-signing-results.mjs +99 -0
|
@@ -177,6 +177,21 @@ function gitObjectDirectory(referencePath) {
|
|
|
177
177
|
return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) || "";
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
function directoryBytes(directory) {
|
|
181
|
+
if (!fs.existsSync(directory)) return 0;
|
|
182
|
+
let bytes = 0;
|
|
183
|
+
const pending = [directory];
|
|
184
|
+
while (pending.length) {
|
|
185
|
+
const current = pending.pop();
|
|
186
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
187
|
+
const child = path.join(current, entry.name);
|
|
188
|
+
if (entry.isDirectory()) pending.push(child);
|
|
189
|
+
else if (entry.isFile()) bytes += fs.statSync(child).size;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return bytes;
|
|
193
|
+
}
|
|
194
|
+
|
|
180
195
|
function writeAlternates(targetPath, referencePath) {
|
|
181
196
|
const objectDirectory = gitObjectDirectory(referencePath);
|
|
182
197
|
if (!objectDirectory) {
|
|
@@ -413,6 +428,13 @@ export function lockedSourceCheckout({
|
|
|
413
428
|
fallbackUsed: normalizedMode === "off",
|
|
414
429
|
fallbackReason: normalizedMode === "off" ? "cache disabled" : "",
|
|
415
430
|
githubFetchAttempts: 0,
|
|
431
|
+
lookupDurationMs: 0,
|
|
432
|
+
restoreDurationMs: 0,
|
|
433
|
+
githubFetchDurationMs: 0,
|
|
434
|
+
restoredBytes: null,
|
|
435
|
+
restoredBytesStatus:
|
|
436
|
+
normalizedMode === "off" ? "not-applicable" : "unavailable",
|
|
437
|
+
restoredBytesMethod: "",
|
|
416
438
|
},
|
|
417
439
|
verification: {
|
|
418
440
|
head: "",
|
|
@@ -425,8 +447,13 @@ export function lockedSourceCheckout({
|
|
|
425
447
|
let checkoutError;
|
|
426
448
|
let checkoutSha = sha;
|
|
427
449
|
if (normalizedMode !== "off") {
|
|
450
|
+
const cacheAttemptStartedAt = Date.now();
|
|
451
|
+
const objectBytesBefore = directoryBytes(
|
|
452
|
+
path.join(targetPath, ".git", "objects"),
|
|
453
|
+
);
|
|
428
454
|
try {
|
|
429
455
|
if (renderedReferenceRepository) {
|
|
456
|
+
const lookupStartedAt = Date.now();
|
|
430
457
|
const alternates = writeAlternates(targetPath, renderedReferenceRepository);
|
|
431
458
|
evidence.cache.transport = "reference-repository";
|
|
432
459
|
evidence.cache.referenceAvailable = alternates;
|
|
@@ -436,9 +463,13 @@ export function lockedSourceCheckout({
|
|
|
436
463
|
if (!hasCommit(targetPath, sha, timeoutMs)) {
|
|
437
464
|
throw new Error("reference repository does not contain source commit");
|
|
438
465
|
}
|
|
466
|
+
evidence.cache.lookupDurationMs = Date.now() - lookupStartedAt;
|
|
467
|
+
const restoreStartedAt = Date.now();
|
|
439
468
|
checkoutFetchedCommit(targetPath, sha, timeoutMs);
|
|
469
|
+
evidence.cache.restoreDurationMs = Date.now() - restoreStartedAt;
|
|
440
470
|
} else if (renderedMirrorUrl) {
|
|
441
471
|
evidence.cache.transport = "mirror-url";
|
|
472
|
+
const restoreStartedAt = Date.now();
|
|
442
473
|
const fetchResult = fetchSourceCommit({
|
|
443
474
|
targetPath,
|
|
444
475
|
remoteName: "buildchain-cache",
|
|
@@ -452,15 +483,29 @@ export function lockedSourceCheckout({
|
|
|
452
483
|
evidence.cache.fetchMode = fetchResult.fetchMode;
|
|
453
484
|
checkoutSha = fetchResult.checkoutSha || sha;
|
|
454
485
|
checkoutFetchedCommit(targetPath, checkoutSha, timeoutMs);
|
|
486
|
+
evidence.cache.restoreDurationMs = Date.now() - restoreStartedAt;
|
|
455
487
|
} else {
|
|
456
488
|
throw new Error("checkout cache is enabled but no mirror URL or reference repository template was provided");
|
|
457
489
|
}
|
|
458
490
|
evidence.cache.hit = true;
|
|
459
491
|
evidence.cache.fallbackUsed = false;
|
|
492
|
+
evidence.cache.restoredBytes = Math.max(
|
|
493
|
+
0,
|
|
494
|
+
directoryBytes(path.join(targetPath, ".git", "objects")) -
|
|
495
|
+
objectBytesBefore,
|
|
496
|
+
);
|
|
497
|
+
evidence.cache.restoredBytesStatus = "observed";
|
|
498
|
+
evidence.cache.restoredBytesMethod = "git-object-store-delta";
|
|
460
499
|
} catch (error) {
|
|
461
500
|
checkoutError = error;
|
|
462
501
|
evidence.cache.hit = false;
|
|
463
502
|
evidence.cache.fallbackReason = error.message;
|
|
503
|
+
if (
|
|
504
|
+
evidence.cache.lookupDurationMs === 0 &&
|
|
505
|
+
evidence.cache.restoreDurationMs === 0
|
|
506
|
+
) {
|
|
507
|
+
evidence.cache.lookupDurationMs = Date.now() - cacheAttemptStartedAt;
|
|
508
|
+
}
|
|
464
509
|
if (normalizedMode === "require" || normalizedFallback === "fail") {
|
|
465
510
|
evidence.durationMs = Date.now() - startedAt;
|
|
466
511
|
writeEvidence(path.resolve(workspace, diagnosticsPath), evidence);
|
|
@@ -477,6 +522,7 @@ export function lockedSourceCheckout({
|
|
|
477
522
|
evidence.cache.fallbackReason = checkoutError.message;
|
|
478
523
|
}
|
|
479
524
|
try {
|
|
525
|
+
const githubFetchStartedAt = Date.now();
|
|
480
526
|
const fetchResult = runBoundedFetch({
|
|
481
527
|
attempts: normalizedFetchAttempts,
|
|
482
528
|
fetch: () => fetchSourceCommit({
|
|
@@ -496,6 +542,8 @@ export function lockedSourceCheckout({
|
|
|
496
542
|
evidence.cache.githubFetchAttempts = fetchResult.attempts;
|
|
497
543
|
evidence.cache.fetchMode = fetchResult.value.fetchMode;
|
|
498
544
|
checkoutSha = fetchResult.value.checkoutSha || sha;
|
|
545
|
+
evidence.cache.githubFetchDurationMs =
|
|
546
|
+
Date.now() - githubFetchStartedAt;
|
|
499
547
|
} catch (error) {
|
|
500
548
|
evidence.durationMs = Date.now() - startedAt;
|
|
501
549
|
writeEvidence(path.resolve(workspace, diagnosticsPath), evidence);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { validateArtifactSigningRequest } from "../packages/core/artifact-signing.js";
|
|
8
|
+
import { writeGitHubOutputs } from "./build-contract-core.mjs";
|
|
9
|
+
|
|
10
|
+
function required(value, label) {
|
|
11
|
+
const normalized = String(value || "").trim();
|
|
12
|
+
if (!normalized) throw new Error(`${label} is required`);
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveBelow(root, relative, label) {
|
|
17
|
+
const target = path.resolve(root, required(relative, label));
|
|
18
|
+
const rel = path.relative(path.resolve(root), target);
|
|
19
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`${label} must resolve below its root`);
|
|
20
|
+
return target;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sha256File(filePath) {
|
|
24
|
+
return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex")}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function materializeArtifactSigningRequest({
|
|
28
|
+
requestRoot = process.env.BUILDCHAIN_SIGNING_REQUEST_ROOT,
|
|
29
|
+
requestPath = process.env.BUILDCHAIN_SIGNING_REQUEST_PATH,
|
|
30
|
+
expectedProfile = process.env.BUILDCHAIN_SIGNING_EXPECTED_PROFILE,
|
|
31
|
+
outputPath = process.env.BUILDCHAIN_UNSIGNED_OUTPUT,
|
|
32
|
+
} = {}) {
|
|
33
|
+
const root = path.resolve(required(requestRoot, "signing request root"));
|
|
34
|
+
const requestFile = resolveBelow(root, requestPath, "request path");
|
|
35
|
+
const request = JSON.parse(fs.readFileSync(requestFile, "utf8"));
|
|
36
|
+
const check = validateArtifactSigningRequest(request);
|
|
37
|
+
if (!check.ok) throw new Error(`invalid artifact signing request: ${check.issues.join(", ")}`);
|
|
38
|
+
if (expectedProfile && request.signature.profile !== expectedProfile) throw new Error("artifact signing profile mismatch");
|
|
39
|
+
if (request.artifact.transport?.format !== "exact-file") throw new Error("native executable signing requires exact-file transport");
|
|
40
|
+
const requestIndexRoot = path.dirname(path.dirname(requestFile));
|
|
41
|
+
const payload = resolveBelow(requestIndexRoot, request.artifact.transport.file, "transport payload");
|
|
42
|
+
const stat = fs.statSync(payload);
|
|
43
|
+
const payloadDigest = sha256File(payload);
|
|
44
|
+
if (!stat.isFile() || stat.size !== request.artifact.transport.bytes || stat.size !== request.artifact.bytes || payloadDigest !== request.artifact.transport.digest || payloadDigest !== request.artifact.digest) {
|
|
45
|
+
throw new Error("signing payload does not match its sealed request");
|
|
46
|
+
}
|
|
47
|
+
const output = path.resolve(required(outputPath, "unsigned output path"));
|
|
48
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
49
|
+
fs.copyFileSync(payload, output, fs.constants.COPYFILE_EXCL);
|
|
50
|
+
writeGitHubOutputs({
|
|
51
|
+
"artifact-id": request.artifact.id,
|
|
52
|
+
"payload-path": output,
|
|
53
|
+
"request-digest": request.digest,
|
|
54
|
+
"signature-provider": request.signature.provider,
|
|
55
|
+
});
|
|
56
|
+
return { request, output };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
60
|
+
try {
|
|
61
|
+
materializeArtifactSigningRequest();
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(`::error::${String(error?.message || error).replace(/\r?\n/gu, "%0A")}`);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { writeGitHubOutputs } from "./build-contract-core.mjs";
|
|
7
|
+
|
|
8
|
+
function required(value, label) {
|
|
9
|
+
const normalized = String(value || "").trim();
|
|
10
|
+
if (!normalized) throw new Error(`${label} is required`);
|
|
11
|
+
return normalized;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function safeId(value) {
|
|
15
|
+
const normalized = String(value || "").replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "");
|
|
16
|
+
if (!normalized || normalized === "." || normalized === "..") throw new Error("unsafe signing result id");
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function walk(root, output = []) {
|
|
21
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
22
|
+
const child = path.join(root, entry.name);
|
|
23
|
+
if (entry.isDirectory()) walk(child, output);
|
|
24
|
+
else if (entry.isFile() && entry.name === "index.json") output.push(child);
|
|
25
|
+
}
|
|
26
|
+
return output;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function mergeArtifactSigningResults({
|
|
30
|
+
inputRoot = process.env.BUILDCHAIN_SIGNING_RESULT_INPUT_ROOT,
|
|
31
|
+
outputRoot = process.env.BUILDCHAIN_SIGNING_RESULT_ROOT,
|
|
32
|
+
} = {}) {
|
|
33
|
+
const input = path.resolve(required(inputRoot, "signing result input root"));
|
|
34
|
+
const output = path.resolve(required(outputRoot, "signing result output root"));
|
|
35
|
+
fs.mkdirSync(output, { recursive: true });
|
|
36
|
+
const merged = [];
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (const indexPath of walk(input)) {
|
|
39
|
+
const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
|
|
40
|
+
if (index.contract !== "kungfu-buildchain-artifact-signing-result-index/v1") continue;
|
|
41
|
+
for (const entry of index.results || []) {
|
|
42
|
+
if (seen.has(entry.id)) throw new Error(`duplicate signing result: ${entry.id}`);
|
|
43
|
+
seen.add(entry.id);
|
|
44
|
+
const sourceResult = path.resolve(path.dirname(indexPath), entry.result);
|
|
45
|
+
const sourceDirectory = path.dirname(sourceResult);
|
|
46
|
+
const relative = path.relative(path.dirname(indexPath), sourceDirectory);
|
|
47
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("signing result escapes provider root");
|
|
48
|
+
const destinationName = safeId(entry.id);
|
|
49
|
+
const destination = path.join(output, destinationName);
|
|
50
|
+
fs.cpSync(sourceDirectory, destination, { recursive: true, errorOnExist: true, force: false });
|
|
51
|
+
merged.push({
|
|
52
|
+
...entry,
|
|
53
|
+
result: `${destinationName}/${path.basename(sourceResult)}`,
|
|
54
|
+
...(entry.payload ? { payload: `${destinationName}/${path.relative(sourceDirectory, path.resolve(path.dirname(indexPath), entry.payload)).split(path.sep).join("/")}` } : {}),
|
|
55
|
+
...(entry.envelope ? { envelope: `${destinationName}/${path.relative(sourceDirectory, path.resolve(path.dirname(indexPath), entry.envelope)).split(path.sep).join("/")}` } : {}),
|
|
56
|
+
...(entry.receipt ? { receipt: `${destinationName}/${path.relative(sourceDirectory, path.resolve(path.dirname(indexPath), entry.receipt)).split(path.sep).join("/")}` } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (merged.length === 0) throw new Error("no artifact signing results found");
|
|
61
|
+
merged.sort((a, b) => a.id.localeCompare(b.id));
|
|
62
|
+
const index = { schemaVersion: 1, contract: "kungfu-buildchain-artifact-signing-result-index/v1", results: merged };
|
|
63
|
+
const indexPath = path.join(output, "index.json");
|
|
64
|
+
fs.writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`);
|
|
65
|
+
writeGitHubOutputs({ "result-count": String(merged.length), "result-index": indexPath, "result-root": output });
|
|
66
|
+
return index;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
70
|
+
try {
|
|
71
|
+
mergeArtifactSigningResults();
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(`::error::${String(error?.message || error).replace(/\r?\n/gu, "%0A")}`);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createGitHubArtifactAttestationVerificationPlan,
|
|
11
|
+
verifyGitHubArtifactAttestationEvidence,
|
|
12
|
+
} from "../packages/core/github-artifact-attestation.js";
|
|
13
|
+
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const result = {};
|
|
16
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
17
|
+
const key = argv[index];
|
|
18
|
+
const value = argv[index + 1];
|
|
19
|
+
if (!key?.startsWith("--") || value === undefined) throw new Error(`invalid argument near ${key || "<end>"}`);
|
|
20
|
+
result[key.slice(2)] = value;
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function required(value, label) {
|
|
26
|
+
const normalized = String(value || "").trim();
|
|
27
|
+
if (!normalized) throw new Error(`${label} is required`);
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sha256(value) {
|
|
32
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sha256File(filePath) {
|
|
36
|
+
return `sha256:${sha256(fs.readFileSync(filePath))}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJson(filePath, label) {
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new Error(`${label} is not readable JSON: ${error.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function safeAssetStem(value) {
|
|
48
|
+
return path.basename(required(value, "subject name"))
|
|
49
|
+
.replace(/[^0-9A-Za-z._-]+/g, "-")
|
|
50
|
+
.replace(/^-+|-+$/g, "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function api({ token, apiUrl, route, method = "GET", headers = {}, body }) {
|
|
54
|
+
const response = await fetch(`${apiUrl}${route}`, {
|
|
55
|
+
method,
|
|
56
|
+
headers: {
|
|
57
|
+
accept: "application/vnd.github+json",
|
|
58
|
+
authorization: `Bearer ${token}`,
|
|
59
|
+
"x-github-api-version": "2022-11-28",
|
|
60
|
+
...headers,
|
|
61
|
+
},
|
|
62
|
+
body,
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const detail = (await response.text()).slice(0, 500);
|
|
66
|
+
throw new Error(`GitHub API ${method} ${route} failed with ${response.status}: ${detail}`);
|
|
67
|
+
}
|
|
68
|
+
return response;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function remoteAssetDigest({ token, apiUrl, repository, asset }) {
|
|
72
|
+
const declared = String(asset.digest || "").match(/^sha256:([0-9a-f]{64})$/i);
|
|
73
|
+
if (declared) return `sha256:${declared[1].toLowerCase()}`;
|
|
74
|
+
const response = await api({
|
|
75
|
+
token,
|
|
76
|
+
apiUrl,
|
|
77
|
+
route: `/repos/${repository}/releases/assets/${asset.id}`,
|
|
78
|
+
headers: { accept: "application/octet-stream" },
|
|
79
|
+
});
|
|
80
|
+
return `sha256:${sha256(Buffer.from(await response.arrayBuffer()))}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function uploadImmutable({ token, apiUrl, repository, release, filePath, assetName }) {
|
|
84
|
+
const localDigest = sha256File(filePath);
|
|
85
|
+
const existing = (release.assets || []).filter((asset) => asset.name === assetName);
|
|
86
|
+
if (existing.length > 1) throw new Error(`release asset ${assetName} exists more than once`);
|
|
87
|
+
if (existing.length === 1) {
|
|
88
|
+
const remoteDigest = await remoteAssetDigest({ token, apiUrl, repository, asset: existing[0] });
|
|
89
|
+
if (remoteDigest !== localDigest) {
|
|
90
|
+
throw new Error(`immutable release asset collision for ${assetName}: ${remoteDigest} != ${localDigest}`);
|
|
91
|
+
}
|
|
92
|
+
return { action: "preserved", name: assetName, digest: localDigest, url: existing[0].browser_download_url };
|
|
93
|
+
}
|
|
94
|
+
const uploadBase = required(release.upload_url, "release.upload_url").replace(/\{.*$/, "");
|
|
95
|
+
const response = await fetch(`${uploadBase}?name=${encodeURIComponent(assetName)}`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: {
|
|
98
|
+
accept: "application/vnd.github+json",
|
|
99
|
+
authorization: `Bearer ${token}`,
|
|
100
|
+
"content-type": "application/octet-stream",
|
|
101
|
+
"x-github-api-version": "2022-11-28",
|
|
102
|
+
},
|
|
103
|
+
body: fs.readFileSync(filePath),
|
|
104
|
+
});
|
|
105
|
+
if (!response.ok) throw new Error(`GitHub release asset upload failed with ${response.status}: ${(await response.text()).slice(0, 500)}`);
|
|
106
|
+
const asset = await response.json();
|
|
107
|
+
release.assets = [...(release.assets || []), asset];
|
|
108
|
+
const remoteDigest = await remoteAssetDigest({ token, apiUrl, repository, asset });
|
|
109
|
+
if (remoteDigest !== localDigest) throw new Error(`release asset read-back mismatch for ${assetName}`);
|
|
110
|
+
return { action: "uploaded", name: assetName, digest: localDigest, url: asset.browser_download_url };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function appendOutput(name, value) {
|
|
114
|
+
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${String(value)}\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function main() {
|
|
118
|
+
const options = parseArgs(process.argv.slice(2));
|
|
119
|
+
const token = required(process.env.GITHUB_TOKEN || process.env.GH_TOKEN, "GITHUB_TOKEN");
|
|
120
|
+
const apiUrl = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "");
|
|
121
|
+
const repository = required(options.repository || process.env.GITHUB_REPOSITORY, "repository");
|
|
122
|
+
const tag = required(options.tag, "tag");
|
|
123
|
+
const subjectPath = path.resolve(required(options.subject, "subject"));
|
|
124
|
+
const manifestPath = path.resolve(required(options.manifest, "manifest"));
|
|
125
|
+
const passportPath = path.resolve(required(options.passport, "passport"));
|
|
126
|
+
const bundlePath = path.resolve(required(options.bundle, "bundle"));
|
|
127
|
+
const evidencePath = path.resolve(required(options.evidence, "evidence"));
|
|
128
|
+
const predicatePath = path.resolve(required(options.predicate, "predicate"));
|
|
129
|
+
const providerVerificationPath = path.resolve(required(options["provider-verification"], "provider-verification"));
|
|
130
|
+
const receiptPath = path.resolve(required(options.receipt, "receipt"));
|
|
131
|
+
const evidence = readJson(evidencePath, "Buildchain attestation evidence");
|
|
132
|
+
const plan = createGitHubArtifactAttestationVerificationPlan({ artifactPath: subjectPath, bundlePath, evidence });
|
|
133
|
+
const provider = spawnSync(plan.command, plan.args, { encoding: "utf8", env: process.env });
|
|
134
|
+
if (provider.status !== 0) throw new Error(`gh attestation verify failed: ${String(provider.stderr || provider.stdout).slice(0, 1000)}`);
|
|
135
|
+
const verificationResults = JSON.parse(provider.stdout);
|
|
136
|
+
const local = verifyGitHubArtifactAttestationEvidence({
|
|
137
|
+
artifactPath: subjectPath,
|
|
138
|
+
platformManifestPath: manifestPath,
|
|
139
|
+
releasePassportPath: passportPath,
|
|
140
|
+
bundlePath,
|
|
141
|
+
evidence,
|
|
142
|
+
verificationResults,
|
|
143
|
+
});
|
|
144
|
+
if (!local.ok) throw new Error(`Buildchain evidence verification failed: ${local.issues.map((issue) => issue.message).join("; ")}`);
|
|
145
|
+
const retainedProvider = readJson(providerVerificationPath, "retained provider verification");
|
|
146
|
+
const retained = verifyGitHubArtifactAttestationEvidence({
|
|
147
|
+
artifactPath: subjectPath,
|
|
148
|
+
platformManifestPath: manifestPath,
|
|
149
|
+
releasePassportPath: passportPath,
|
|
150
|
+
bundlePath,
|
|
151
|
+
evidence,
|
|
152
|
+
verificationResults: retainedProvider,
|
|
153
|
+
});
|
|
154
|
+
if (!retained.ok) {
|
|
155
|
+
throw new Error(`retained provider verification failed: ${retained.issues.map((issue) => issue.message).join("; ")}`);
|
|
156
|
+
}
|
|
157
|
+
const releaseResponse = await api({ token, apiUrl, route: `/repos/${repository}/releases/tags/${encodeURIComponent(tag)}` });
|
|
158
|
+
const release = await releaseResponse.json();
|
|
159
|
+
const stem = safeAssetStem(evidence.subject?.name);
|
|
160
|
+
const declarations = [
|
|
161
|
+
[bundlePath, `${stem}.sigstore-bundle.json`],
|
|
162
|
+
[evidencePath, `${stem}.buildchain-attestation.json`],
|
|
163
|
+
[predicatePath, `${stem}.buildchain-predicate.json`],
|
|
164
|
+
[providerVerificationPath, `${stem}.github-verification.json`],
|
|
165
|
+
];
|
|
166
|
+
const assets = [];
|
|
167
|
+
for (const [filePath, assetName] of declarations) {
|
|
168
|
+
assets.push(await uploadImmutable({ token, apiUrl, repository, release, filePath, assetName }));
|
|
169
|
+
}
|
|
170
|
+
const receipt = {
|
|
171
|
+
contract: "buildchain.github-artifact-attestation-publication/v1",
|
|
172
|
+
repository,
|
|
173
|
+
tag,
|
|
174
|
+
releaseUrl: release.html_url,
|
|
175
|
+
subject: evidence.subject,
|
|
176
|
+
evidenceRoot: evidence.evidenceRoot,
|
|
177
|
+
attestation: evidence.attestation,
|
|
178
|
+
assets,
|
|
179
|
+
verified: true,
|
|
180
|
+
};
|
|
181
|
+
fs.mkdirSync(path.dirname(receiptPath), { recursive: true });
|
|
182
|
+
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
183
|
+
const receiptAsset = await uploadImmutable({
|
|
184
|
+
token,
|
|
185
|
+
apiUrl,
|
|
186
|
+
repository,
|
|
187
|
+
release,
|
|
188
|
+
filePath: receiptPath,
|
|
189
|
+
assetName: `${stem}.buildchain-attestation-publication.json`,
|
|
190
|
+
});
|
|
191
|
+
appendOutput("release-url", release.html_url);
|
|
192
|
+
appendOutput("evidence-root", evidence.evidenceRoot);
|
|
193
|
+
appendOutput("publication-receipt", receiptPath);
|
|
194
|
+
appendOutput("publication-receipt-digest", receiptAsset.digest);
|
|
195
|
+
process.stdout.write(`${JSON.stringify({ ...receipt, receiptAsset }, null, 2)}\n`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
main().catch((error) => {
|
|
199
|
+
console.error(error.message);
|
|
200
|
+
process.exitCode = 1;
|
|
201
|
+
});
|