@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
|
@@ -55,6 +55,17 @@ const SUPPORTED_INFRA_APPLY_MODES = new Set(["disabled", "manual-approval", "env
|
|
|
55
55
|
const SUPPORTED_FACT_VERSION_SOURCE_TYPES = new Set(["static", "json", "toml", "regex", "command"]);
|
|
56
56
|
const SUPPORTED_FACT_LEGACY_PROJECTIONS = new Set(["kungfu-buildinfo"]);
|
|
57
57
|
const SUPPORTED_PUBLICATION_TOOLCHAINS = new Set(["custom-command", "latex-docker"]);
|
|
58
|
+
const SUPPORTED_SIGNING_PROFILES = new Set([
|
|
59
|
+
"auto",
|
|
60
|
+
"apple-developer-id",
|
|
61
|
+
"windows-authenticode",
|
|
62
|
+
"detached-signature-v1",
|
|
63
|
+
]);
|
|
64
|
+
const SUPPORTED_SIGNING_ARTIFACT_KINDS = new Set([
|
|
65
|
+
"auto", "binary", "archive", "blob", "directory", "mach-o", "app-bundle",
|
|
66
|
+
"framework-bundle", "plugin-bundle", "xpc-bundle", "dylib", "pkg", "dmg",
|
|
67
|
+
"pe",
|
|
68
|
+
]);
|
|
58
69
|
|
|
59
70
|
function posixPath(value) {
|
|
60
71
|
return String(value || "").split(path.sep).join("/");
|
|
@@ -218,6 +229,9 @@ export function normalizeBuildchainConfig(config) {
|
|
|
218
229
|
if (normalized.facts !== undefined) {
|
|
219
230
|
normalized.facts = normalizeFactsSection(normalized.facts);
|
|
220
231
|
}
|
|
232
|
+
if (normalized.signing !== undefined) {
|
|
233
|
+
normalized.signing = normalizeSigningSection(normalized.signing);
|
|
234
|
+
}
|
|
221
235
|
validateWebSurfaceConfig(normalized);
|
|
222
236
|
validateInfraContractConfig(normalized);
|
|
223
237
|
validateAnchoredDerivedVersionMaterialConfig(normalized);
|
|
@@ -335,6 +349,49 @@ function normalizeFactsSection(facts) {
|
|
|
335
349
|
};
|
|
336
350
|
}
|
|
337
351
|
|
|
352
|
+
function normalizeSigningSection(signing) {
|
|
353
|
+
assertPlainObject(signing, "signing");
|
|
354
|
+
for (const key of Object.keys(signing)) {
|
|
355
|
+
if (key !== "artifacts") {
|
|
356
|
+
throw new Error(`signing.${key} is not supported; consumer signing declarations cannot configure credentials or authority infrastructure`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (!Array.isArray(signing.artifacts) || signing.artifacts.length === 0) {
|
|
360
|
+
throw new Error("signing.artifacts must declare at least one artifact");
|
|
361
|
+
}
|
|
362
|
+
const artifacts = signing.artifacts.map((artifact, index) => {
|
|
363
|
+
const label = `signing.artifacts[${index}]`;
|
|
364
|
+
assertPlainObject(artifact, label);
|
|
365
|
+
const allowedKeys = new Set(["id", "path", "profile", "kind", "platforms", "required"]);
|
|
366
|
+
for (const key of Object.keys(artifact)) {
|
|
367
|
+
if (!allowedKeys.has(key)) {
|
|
368
|
+
throw new Error(`${label}.${key} is not supported; declare desired signature state only`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const artifactPath = posixPath(assertString(artifact.path, `${label}.path`));
|
|
372
|
+
const profile = artifact.profile === undefined ? "auto" : assertString(artifact.profile, `${label}.profile`);
|
|
373
|
+
if (!SUPPORTED_SIGNING_PROFILES.has(profile)) {
|
|
374
|
+
throw new Error(`${label}.profile must be one of auto, apple-developer-id, windows-authenticode, or detached-signature-v1`);
|
|
375
|
+
}
|
|
376
|
+
const kind = artifact.kind === undefined ? "auto" : assertString(artifact.kind, `${label}.kind`);
|
|
377
|
+
if (!SUPPORTED_SIGNING_ARTIFACT_KINDS.has(kind)) {
|
|
378
|
+
throw new Error(`${label}.kind is not a supported signing artifact kind`);
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
id: artifact.id === undefined ? artifactPath : assertString(artifact.id, `${label}.id`),
|
|
382
|
+
path: artifactPath,
|
|
383
|
+
profile,
|
|
384
|
+
kind,
|
|
385
|
+
platforms: normalizeStringArray(artifact.platforms, `${label}.platforms`),
|
|
386
|
+
required: optionalBoolean(artifact.required, true),
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
if (new Set(artifacts.map((artifact) => artifact.id)).size !== artifacts.length) {
|
|
390
|
+
throw new Error("signing.artifacts must use unique ids");
|
|
391
|
+
}
|
|
392
|
+
return { artifacts };
|
|
393
|
+
}
|
|
394
|
+
|
|
338
395
|
function normalizeFactVersionSources(sources = []) {
|
|
339
396
|
if (sources === undefined) {
|
|
340
397
|
return [];
|
|
@@ -1615,6 +1672,7 @@ export function validateBuildchainConfig(
|
|
|
1615
1672
|
release: loadedConfig.config.release,
|
|
1616
1673
|
governance: loadedConfig.config.governance,
|
|
1617
1674
|
facts: loadedConfig.config.facts,
|
|
1675
|
+
signing: loadedConfig.config.signing,
|
|
1618
1676
|
publication: loadedConfig.config.publication,
|
|
1619
1677
|
};
|
|
1620
1678
|
}
|
|
@@ -121,6 +121,8 @@ export function createBuildchainContractWorld({
|
|
|
121
121
|
"runner-preset",
|
|
122
122
|
"platforms-json",
|
|
123
123
|
"release-candidate",
|
|
124
|
+
"github-artifact-attestation-subject-path",
|
|
125
|
+
"github-artifact-attestation-platform-id",
|
|
124
126
|
"artifact-transfer-mode",
|
|
125
127
|
"checkout-cache-mode",
|
|
126
128
|
"checkout-cache-fallback",
|
|
@@ -196,6 +198,9 @@ export function createBuildchainContractWorld({
|
|
|
196
198
|
"release-passport-kfd-3-artifact-verify-command",
|
|
197
199
|
"release-passport-invariant-passport-jsons",
|
|
198
200
|
"release-passport-invariant-passport-command",
|
|
201
|
+
"github-artifact-attestation-policy-json",
|
|
202
|
+
"github-artifact-attestation-environment",
|
|
203
|
+
"github-artifact-attestation-retention-days",
|
|
199
204
|
"buildchain-contract-lock-path",
|
|
200
205
|
"buildchain-contract-drift-issue-mode",
|
|
201
206
|
"github-release",
|
|
@@ -324,10 +329,13 @@ export function createBuildchainContractWorld({
|
|
|
324
329
|
"media-artifact-digest",
|
|
325
330
|
"media-artifact-url",
|
|
326
331
|
"media-root",
|
|
332
|
+
"media-profile",
|
|
333
|
+
"media-qualification-root",
|
|
327
334
|
],
|
|
328
335
|
breakingDefaults: {
|
|
329
336
|
trustedEventRequired: true,
|
|
330
337
|
renderMediaDefault: false,
|
|
338
|
+
mediaProfileDefault: "archive-v1",
|
|
331
339
|
artifactRetentionDaysDefault: 14,
|
|
332
340
|
gatePolicy: "required-before-selective-render",
|
|
333
341
|
},
|
|
@@ -335,6 +343,7 @@ export function createBuildchainContractWorld({
|
|
|
335
343
|
"buildchain-repository",
|
|
336
344
|
"source-ref",
|
|
337
345
|
"render-media",
|
|
346
|
+
"media-profile",
|
|
338
347
|
"artifact-retention-days",
|
|
339
348
|
"require-trusted-event",
|
|
340
349
|
],
|
|
@@ -344,6 +353,10 @@ export function createBuildchainContractWorld({
|
|
|
344
353
|
"every invocation runs an immutable network-disabled renderer smoke before emitting a qualified Gate bundle",
|
|
345
354
|
"complete media rendering is optional and consumes only an exact passing Gate bundle",
|
|
346
355
|
"Gate and media bundles expose both GitHub Artifact archive coordinates and deterministic member roots",
|
|
356
|
+
"explicit web-delivery profiles independently verify codec, container, audio, dimensions, duration, byte budgets, rendition roles, and MP4 fast-start evidence",
|
|
357
|
+
"media receipts expose content-addressed rendition facts without requiring consumers to infer roles from filenames",
|
|
358
|
+
"Build Images owns encoding, Buildchain owns qualification and receipts, and site repositories own browser loading and accessibility behavior",
|
|
359
|
+
"media qualification does not claim browser playback, responsive layout, reduced-motion behavior, accessibility, or production deployment",
|
|
347
360
|
],
|
|
348
361
|
}),
|
|
349
362
|
surface(root, {
|
|
@@ -369,6 +382,7 @@ export function createBuildchainContractWorld({
|
|
|
369
382
|
"release-passport-kfd-3-artifact-verify-command",
|
|
370
383
|
"release-passport-invariant-passport-jsons",
|
|
371
384
|
"release-passport-invariant-passport-command",
|
|
385
|
+
"release-passport-github-artifact-attestation-policy-jsons",
|
|
372
386
|
"github-release",
|
|
373
387
|
"github-release-title",
|
|
374
388
|
"github-release-notes",
|
|
@@ -16,6 +16,7 @@ export const BUILDCHAIN_AGENT_MANUALS = Object.freeze([
|
|
|
16
16
|
{ id: "auditable-demo", title: "Auditable demo artifact pipeline", path: "docs/auditable-demo.md", plane: "verify" },
|
|
17
17
|
{ id: "install", title: "Install and verify Buildchain", path: "docs/install.md", plane: "use" },
|
|
18
18
|
{ id: "release-passport", title: "Release Passport protocol", path: "docs/release-passport.md", plane: "verify" },
|
|
19
|
+
{ id: "github-artifact-attestation", title: "GitHub-native Linux artifact attestation", path: "docs/github-artifact-attestation.md", plane: "verify" },
|
|
19
20
|
{ id: "controller-evidence", title: "Controller evidence contract", path: "docs/controller-evidence.md", plane: "verify" },
|
|
20
21
|
{ id: "publication-authority", title: "Sealed publication authority", path: "docs/publication-authority.md", plane: "verify" },
|
|
21
22
|
{ id: "github-governance-authority", title: "GitHub governance authority", path: "docs/github-governance-authority.md", plane: "verify" },
|
|
@@ -71,6 +72,7 @@ const SITE_CONTRACT_FILES = Object.freeze([
|
|
|
71
72
|
const SCHEMA_AND_STANDARD_FILES = Object.freeze([
|
|
72
73
|
"packages/core/kfd-gate.js",
|
|
73
74
|
"packages/core/release-passport.js",
|
|
75
|
+
"packages/core/github-artifact-attestation.js",
|
|
74
76
|
"packages/core/buildchain-contract.js",
|
|
75
77
|
"packages/core/controller-evidence.js",
|
|
76
78
|
"packages/core/publication-authority.js",
|
|
@@ -89,6 +91,9 @@ const WORKFLOW_AND_ACTION_FILES = Object.freeze([
|
|
|
89
91
|
".github/workflows/.release-candidate-promote.yml",
|
|
90
92
|
".github/workflows/release-candidate-promote.yml",
|
|
91
93
|
".github/workflows/buildchain-ref-promotion.yml",
|
|
94
|
+
".github/workflows/github-artifact-attestation.yml",
|
|
95
|
+
"actions/github-artifact-attestation/action.yml",
|
|
96
|
+
"actions/github-artifact-attestation/index.js",
|
|
92
97
|
".github/workflows/release-propagation.yml",
|
|
93
98
|
"actions/macos-credential-island/action.yml",
|
|
94
99
|
"actions/macos-credential-island/index.js",
|
|
@@ -18,6 +18,7 @@ const DESCRIPTORS = Object.freeze([
|
|
|
18
18
|
[".github/workflows/.binary-release-assets.yml", "product-publication", true, ["github-release"], "github-token", "buildchain-release-assets"],
|
|
19
19
|
[".github/workflows/binary-distribution.yml", "evidence-publication"],
|
|
20
20
|
[".github/workflows/binary-release-assets.yml", "governance-write"],
|
|
21
|
+
[".github/workflows/artifact-signing-authority.yml", "evidence-publication"],
|
|
21
22
|
[".github/workflows/build-surface-fixture.yml", "non-publication-oidc"],
|
|
22
23
|
[".github/workflows/build.yml", "non-publication-oidc"],
|
|
23
24
|
[".github/workflows/buildchain-alpha-self-dogfood.yml", "non-publication-oidc"],
|
|
@@ -27,9 +28,11 @@ const DESCRIPTORS = Object.freeze([
|
|
|
27
28
|
[".github/workflows/buildchain-patrol.yml", "governance-write"],
|
|
28
29
|
[".github/workflows/buildchain-ref-promotion.yml", "governance-write"],
|
|
29
30
|
[".github/workflows/buildchain-stable-candidate-patrol.yml", "governance-write"],
|
|
31
|
+
[".github/workflows/dev-alpha-candidate-patrol.yml", "governance-write"],
|
|
30
32
|
[".github/workflows/dev-merge-queue-governance.yml", "governance-write"],
|
|
31
33
|
[".github/workflows/dev-pr-auto-merge.yml", "governance-write"],
|
|
32
34
|
[".github/workflows/github-governance-audit.yml", "governance-write"],
|
|
35
|
+
[".github/workflows/github-artifact-attestation.yml", "evidence-publication", true, ["github-artifact-attestation"], "oidc", "consumer-defined", "caller-bound", "caller-bound"],
|
|
33
36
|
[".github/workflows/npm-publish.yml", "dry-run-only"],
|
|
34
37
|
[".github/workflows/paper-release-sealed.yml", "product-publication", true, ["npm-publish", "github-release"], "trusted-publishing", "none", "fixed", "caller-bound"],
|
|
35
38
|
[".github/workflows/paper-release.yml", "product-publication", true, ["npm-publish", "github-release"], "trusted-publishing", "none", "fixed", "caller-bound"],
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const BUILDCHAIN_CACHE_OPERATION_RECEIPT_CONTRACT =
|
|
4
|
+
"buildchain.cache-operation-receipt/v1";
|
|
5
|
+
export const BUILDCHAIN_CACHE_EVIDENCE_SET_CONTRACT =
|
|
6
|
+
"buildchain.cache-evidence-set/v1";
|
|
7
|
+
|
|
8
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
9
|
+
const OUTCOMES = new Set([
|
|
10
|
+
"hit",
|
|
11
|
+
"miss",
|
|
12
|
+
"partial",
|
|
13
|
+
"bypassed",
|
|
14
|
+
"poisoned",
|
|
15
|
+
"unavailable",
|
|
16
|
+
]);
|
|
17
|
+
const METRIC_UNITS = Object.freeze({
|
|
18
|
+
lookupDuration: "ms",
|
|
19
|
+
restoreDuration: "ms",
|
|
20
|
+
saveDuration: "ms",
|
|
21
|
+
restoredBytes: "bytes",
|
|
22
|
+
writtenBytes: "bytes",
|
|
23
|
+
savedTime: "ms",
|
|
24
|
+
});
|
|
25
|
+
const METRIC_STATUSES = new Set(["observed", "unavailable", "not-applicable"]);
|
|
26
|
+
const SAVED_TIME_METHODS = new Set(["producer-measured", "provider-reported"]);
|
|
27
|
+
|
|
28
|
+
function assert(condition, message) {
|
|
29
|
+
if (!condition) throw new Error(message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ordered(value) {
|
|
33
|
+
if (Array.isArray(value)) return value.map(ordered);
|
|
34
|
+
if (value && typeof value === "object") {
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
Object.keys(value)
|
|
37
|
+
.sort()
|
|
38
|
+
.map((key) => [key, ordered(value[key])]),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function stableJson(value) {
|
|
45
|
+
return JSON.stringify(ordered(value));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function cacheEvidenceDigest(value) {
|
|
49
|
+
return `sha256:${crypto
|
|
50
|
+
.createHash("sha256")
|
|
51
|
+
.update(typeof value === "string" ? value : stableJson(value))
|
|
52
|
+
.digest("hex")}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function exactKeys(value, allowed, label) {
|
|
56
|
+
assert(
|
|
57
|
+
value && typeof value === "object" && !Array.isArray(value),
|
|
58
|
+
`${label} must be an object`,
|
|
59
|
+
);
|
|
60
|
+
for (const key of Object.keys(value)) {
|
|
61
|
+
assert(allowed.has(key), `${label}.${key} is not allowed`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function text(value, label) {
|
|
66
|
+
assert(
|
|
67
|
+
typeof value === "string" && value.trim() === value && value.length > 0,
|
|
68
|
+
`${label} is required`,
|
|
69
|
+
);
|
|
70
|
+
assert(!/[\r\n\0]/.test(value), `${label} contains control characters`);
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function digest(value, label) {
|
|
75
|
+
assert(DIGEST_RE.test(value), `${label} must be a sha256 digest`);
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeBindings(bindings = {}) {
|
|
80
|
+
exactKeys(
|
|
81
|
+
bindings,
|
|
82
|
+
new Set([
|
|
83
|
+
"sourceCommit",
|
|
84
|
+
"sourceTree",
|
|
85
|
+
"runtimeCommit",
|
|
86
|
+
"dependencyLockRoot",
|
|
87
|
+
"toolchainRoot",
|
|
88
|
+
"policyRoot",
|
|
89
|
+
"platformRoot",
|
|
90
|
+
"cacheProfileRoot",
|
|
91
|
+
]),
|
|
92
|
+
"bindings",
|
|
93
|
+
);
|
|
94
|
+
return Object.fromEntries(
|
|
95
|
+
Object.entries(bindings)
|
|
96
|
+
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
|
97
|
+
.map(([key, value]) => [key, text(value, `bindings.${key}`)]),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeMetric(metric, name) {
|
|
102
|
+
exactKeys(
|
|
103
|
+
metric,
|
|
104
|
+
new Set([
|
|
105
|
+
"status",
|
|
106
|
+
"unit",
|
|
107
|
+
"value",
|
|
108
|
+
"source",
|
|
109
|
+
"reason",
|
|
110
|
+
"evidenceRoot",
|
|
111
|
+
"method",
|
|
112
|
+
]),
|
|
113
|
+
`metrics.${name}`,
|
|
114
|
+
);
|
|
115
|
+
const status = text(metric.status, `metrics.${name}.status`);
|
|
116
|
+
assert(
|
|
117
|
+
METRIC_STATUSES.has(status),
|
|
118
|
+
`metrics.${name}.status must be observed, unavailable, or not-applicable`,
|
|
119
|
+
);
|
|
120
|
+
assert(
|
|
121
|
+
metric.unit === METRIC_UNITS[name],
|
|
122
|
+
`metrics.${name}.unit must be ${METRIC_UNITS[name]}`,
|
|
123
|
+
);
|
|
124
|
+
if (status === "observed") {
|
|
125
|
+
assert(
|
|
126
|
+
Number.isFinite(metric.value) && metric.value >= 0,
|
|
127
|
+
`metrics.${name}.value must be a non-negative finite number`,
|
|
128
|
+
);
|
|
129
|
+
const normalized = {
|
|
130
|
+
status,
|
|
131
|
+
unit: metric.unit,
|
|
132
|
+
value: metric.value,
|
|
133
|
+
source: text(metric.source, `metrics.${name}.source`),
|
|
134
|
+
reason: null,
|
|
135
|
+
evidenceRoot: digest(
|
|
136
|
+
metric.evidenceRoot,
|
|
137
|
+
`metrics.${name}.evidenceRoot`,
|
|
138
|
+
),
|
|
139
|
+
};
|
|
140
|
+
if (name === "savedTime") {
|
|
141
|
+
assert(
|
|
142
|
+
typeof metric.method === "string" &&
|
|
143
|
+
SAVED_TIME_METHODS.has(metric.method),
|
|
144
|
+
"observed saved time requires producer-measured or provider-reported evidence",
|
|
145
|
+
);
|
|
146
|
+
normalized.method = metric.method;
|
|
147
|
+
} else if (metric.method) {
|
|
148
|
+
normalized.method = text(metric.method, `metrics.${name}.method`);
|
|
149
|
+
}
|
|
150
|
+
return normalized;
|
|
151
|
+
}
|
|
152
|
+
assert(
|
|
153
|
+
metric.value === null || metric.value === undefined,
|
|
154
|
+
`metrics.${name}.value must be null when ${status}`,
|
|
155
|
+
);
|
|
156
|
+
return {
|
|
157
|
+
status,
|
|
158
|
+
unit: metric.unit,
|
|
159
|
+
value: null,
|
|
160
|
+
source: metric.source ? text(metric.source, `metrics.${name}.source`) : null,
|
|
161
|
+
reason: text(metric.reason, `metrics.${name}.reason`),
|
|
162
|
+
evidenceRoot: metric.evidenceRoot
|
|
163
|
+
? digest(metric.evidenceRoot, `metrics.${name}.evidenceRoot`)
|
|
164
|
+
: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function createCacheOperationReceipt({
|
|
169
|
+
operationId,
|
|
170
|
+
operation,
|
|
171
|
+
provider,
|
|
172
|
+
producer,
|
|
173
|
+
platform,
|
|
174
|
+
cacheKey,
|
|
175
|
+
cacheRoot,
|
|
176
|
+
outcome,
|
|
177
|
+
bindings = {},
|
|
178
|
+
metrics,
|
|
179
|
+
evidence,
|
|
180
|
+
} = {}) {
|
|
181
|
+
assert(
|
|
182
|
+
["restore", "save", "lookup"].includes(operation),
|
|
183
|
+
"operation must be restore, save, or lookup",
|
|
184
|
+
);
|
|
185
|
+
assert(OUTCOMES.has(outcome), "unsupported cache outcome");
|
|
186
|
+
exactKeys(
|
|
187
|
+
metrics,
|
|
188
|
+
new Set(Object.keys(METRIC_UNITS)),
|
|
189
|
+
"metrics",
|
|
190
|
+
);
|
|
191
|
+
for (const name of Object.keys(METRIC_UNITS)) {
|
|
192
|
+
assert(metrics[name], `metrics.${name} is required`);
|
|
193
|
+
}
|
|
194
|
+
exactKeys(
|
|
195
|
+
evidence,
|
|
196
|
+
new Set(["kind", "root", "locator"]),
|
|
197
|
+
"evidence",
|
|
198
|
+
);
|
|
199
|
+
const receipt = {
|
|
200
|
+
schema: BUILDCHAIN_CACHE_OPERATION_RECEIPT_CONTRACT,
|
|
201
|
+
operationId: text(operationId, "operationId"),
|
|
202
|
+
operation,
|
|
203
|
+
provider: text(provider, "provider"),
|
|
204
|
+
producer: text(producer, "producer"),
|
|
205
|
+
platform: text(platform, "platform"),
|
|
206
|
+
cacheKey: cacheKey ? text(cacheKey, "cacheKey") : null,
|
|
207
|
+
cacheRoot: cacheRoot ? text(cacheRoot, "cacheRoot") : null,
|
|
208
|
+
outcome,
|
|
209
|
+
bindings: normalizeBindings(bindings),
|
|
210
|
+
metrics: Object.fromEntries(
|
|
211
|
+
Object.keys(METRIC_UNITS).map((name) => [
|
|
212
|
+
name,
|
|
213
|
+
normalizeMetric(metrics[name], name),
|
|
214
|
+
]),
|
|
215
|
+
),
|
|
216
|
+
evidence: {
|
|
217
|
+
kind: text(evidence.kind, "evidence.kind"),
|
|
218
|
+
root: digest(evidence.root, "evidence.root"),
|
|
219
|
+
locator: text(evidence.locator, "evidence.locator"),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
return { ...receipt, receiptRoot: cacheEvidenceDigest(receipt) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function verifyCacheOperationReceipt(receipt) {
|
|
226
|
+
assert(
|
|
227
|
+
receipt?.schema === BUILDCHAIN_CACHE_OPERATION_RECEIPT_CONTRACT,
|
|
228
|
+
"cache operation receipt schema mismatch",
|
|
229
|
+
);
|
|
230
|
+
const { receiptRoot, ...body } = receipt;
|
|
231
|
+
assert(
|
|
232
|
+
receiptRoot === cacheEvidenceDigest(body),
|
|
233
|
+
"cache operation receipt root mismatch",
|
|
234
|
+
);
|
|
235
|
+
const rebuilt = createCacheOperationReceipt(body);
|
|
236
|
+
assert(
|
|
237
|
+
stableJson(rebuilt) === stableJson(receipt),
|
|
238
|
+
"cache operation receipt normalization drift",
|
|
239
|
+
);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function createCacheEvidenceSet({
|
|
244
|
+
repository,
|
|
245
|
+
sourceCommit,
|
|
246
|
+
sourceTree = "",
|
|
247
|
+
runtimeCommit = "",
|
|
248
|
+
platform,
|
|
249
|
+
operations = [],
|
|
250
|
+
} = {}) {
|
|
251
|
+
assert(Array.isArray(operations), "cache evidence operations must be an array");
|
|
252
|
+
operations.forEach(verifyCacheOperationReceipt);
|
|
253
|
+
const operationIds = operations.map(({ operationId }) => operationId);
|
|
254
|
+
assert(
|
|
255
|
+
operationIds.length === new Set(operationIds).size,
|
|
256
|
+
"cache evidence operation ids must be unique",
|
|
257
|
+
);
|
|
258
|
+
const value = {
|
|
259
|
+
schema: BUILDCHAIN_CACHE_EVIDENCE_SET_CONTRACT,
|
|
260
|
+
repository: text(repository, "repository"),
|
|
261
|
+
sourceCommit: text(sourceCommit, "sourceCommit"),
|
|
262
|
+
sourceTree: sourceTree ? text(sourceTree, "sourceTree") : null,
|
|
263
|
+
runtimeCommit: runtimeCommit ? text(runtimeCommit, "runtimeCommit") : null,
|
|
264
|
+
platform: text(platform, "platform"),
|
|
265
|
+
operations: [...operations].sort((left, right) =>
|
|
266
|
+
left.operationId.localeCompare(right.operationId),
|
|
267
|
+
),
|
|
268
|
+
};
|
|
269
|
+
return { ...value, evidenceRoot: cacheEvidenceDigest(value) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function verifyCacheEvidenceSet(receipt) {
|
|
273
|
+
assert(
|
|
274
|
+
receipt?.schema === BUILDCHAIN_CACHE_EVIDENCE_SET_CONTRACT,
|
|
275
|
+
"cache evidence set schema mismatch",
|
|
276
|
+
);
|
|
277
|
+
const { evidenceRoot, ...body } = receipt;
|
|
278
|
+
assert(
|
|
279
|
+
evidenceRoot === cacheEvidenceDigest(body),
|
|
280
|
+
"cache evidence set root mismatch",
|
|
281
|
+
);
|
|
282
|
+
const rebuilt = createCacheEvidenceSet(body);
|
|
283
|
+
assert(
|
|
284
|
+
stableJson(rebuilt) === stableJson(receipt),
|
|
285
|
+
"cache evidence set normalization drift",
|
|
286
|
+
);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const CHANNEL_CANDIDATE_DECISION_SCHEMA =
|
|
6
|
+
"kungfu-buildchain-channel-candidate-decision/v1";
|
|
7
|
+
|
|
8
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
9
|
+
|
|
10
|
+
function canonical(value) {
|
|
11
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
12
|
+
if (value && typeof value === "object") {
|
|
13
|
+
return Object.fromEntries(
|
|
14
|
+
Object.entries(value)
|
|
15
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
16
|
+
.map(([key, item]) => [key, canonical(item)]),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function root(value) {
|
|
23
|
+
return `sha256:${crypto
|
|
24
|
+
.createHash("sha256")
|
|
25
|
+
.update(JSON.stringify(canonical(value)))
|
|
26
|
+
.digest("hex")}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function required(value, name) {
|
|
30
|
+
const normalized = String(value || "").trim();
|
|
31
|
+
if (!normalized) throw new Error(`${name} is required`);
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sha(value, name) {
|
|
36
|
+
const normalized = required(value, name).toLowerCase();
|
|
37
|
+
if (!SHA.test(normalized))
|
|
38
|
+
throw new Error(`${name} must be an exact 40-character SHA`);
|
|
39
|
+
return normalized;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function qualifyWorkflow(row, { sourceSha, now, maxAgeSeconds }) {
|
|
43
|
+
if (!row || typeof row !== "object")
|
|
44
|
+
throw new Error("workflow evidence is required");
|
|
45
|
+
const workflowPath = required(row.workflowPath, "workflowPath");
|
|
46
|
+
if (sha(row.headSha, `${workflowPath} headSha`) !== sourceSha) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${workflowPath} evidence does not bind source SHA ${sourceSha}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (row.status !== "completed" || row.conclusion !== "success") {
|
|
52
|
+
throw new Error(`${workflowPath} is not a completed successful run`);
|
|
53
|
+
}
|
|
54
|
+
const completedAt = required(row.completedAt, `${workflowPath} completedAt`);
|
|
55
|
+
const ageSeconds = (Date.parse(now) - Date.parse(completedAt)) / 1000;
|
|
56
|
+
if (
|
|
57
|
+
!Number.isFinite(ageSeconds) ||
|
|
58
|
+
ageSeconds < 0 ||
|
|
59
|
+
ageSeconds > maxAgeSeconds
|
|
60
|
+
) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${workflowPath} evidence is stale or has an invalid completion time`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const runId = Number(row.runId);
|
|
66
|
+
const runAttempt = Number(row.runAttempt || 1);
|
|
67
|
+
if (!Number.isSafeInteger(runId) || runId <= 0)
|
|
68
|
+
throw new Error(`${workflowPath} runId is invalid`);
|
|
69
|
+
if (!Number.isSafeInteger(runAttempt) || runAttempt <= 0)
|
|
70
|
+
throw new Error(`${workflowPath} runAttempt is invalid`);
|
|
71
|
+
return {
|
|
72
|
+
workflowPath,
|
|
73
|
+
workflowName: required(row.workflowName, `${workflowPath} workflowName`),
|
|
74
|
+
runId,
|
|
75
|
+
runAttempt,
|
|
76
|
+
headSha: sourceSha,
|
|
77
|
+
status: "completed",
|
|
78
|
+
conclusion: "success",
|
|
79
|
+
completedAt,
|
|
80
|
+
url: required(row.url, `${workflowPath} url`),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function channelCandidateSourceLockRef(targetBranch, sourceSha) {
|
|
85
|
+
const target = required(targetBranch, "targetBranch");
|
|
86
|
+
const exactSha = sha(sourceSha, "sourceSha");
|
|
87
|
+
return `buildchain/candidate/${target.replace(/[^A-Za-z0-9._-]+/g, "-")}/${exactSha.slice(0, 12)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function decideChannelCandidate(input) {
|
|
91
|
+
const repository = required(input.repository, "repository");
|
|
92
|
+
const sourceBranch = required(input.sourceBranch, "sourceBranch");
|
|
93
|
+
const targetBranch = required(input.targetBranch, "targetBranch");
|
|
94
|
+
if (sourceBranch === targetBranch)
|
|
95
|
+
throw new Error("sourceBranch and targetBranch must differ");
|
|
96
|
+
const sourceSha = sha(input.sourceSha, "sourceSha");
|
|
97
|
+
const targetSha = sha(input.targetSha, "targetSha");
|
|
98
|
+
const selection = input.selection
|
|
99
|
+
? {
|
|
100
|
+
mode: required(input.selection.mode, "selection.mode"),
|
|
101
|
+
observedSourceHeadSha: sha(
|
|
102
|
+
input.selection.observedSourceHeadSha,
|
|
103
|
+
"selection.observedSourceHeadSha",
|
|
104
|
+
),
|
|
105
|
+
skippedNewerCommitCount: Number(
|
|
106
|
+
input.selection.skippedNewerCommitCount || 0,
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
: undefined;
|
|
110
|
+
if (
|
|
111
|
+
selection &&
|
|
112
|
+
(!Number.isSafeInteger(selection.skippedNewerCommitCount) ||
|
|
113
|
+
selection.skippedNewerCommitCount < 0)
|
|
114
|
+
) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
"selection.skippedNewerCommitCount must be a non-negative integer",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const now = required(input.now || new Date().toISOString(), "now");
|
|
120
|
+
const maxAgeSeconds = Number(input.maxAgeSeconds ?? 7 * 24 * 60 * 60);
|
|
121
|
+
if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds <= 0) {
|
|
122
|
+
throw new Error("maxAgeSeconds must be a positive integer");
|
|
123
|
+
}
|
|
124
|
+
const comparison = input.comparison || {};
|
|
125
|
+
if (comparison.status !== "ahead" || Number(comparison.aheadBy) <= 0) {
|
|
126
|
+
const reason =
|
|
127
|
+
comparison.status === "identical"
|
|
128
|
+
? "target-already-current"
|
|
129
|
+
: "source-does-not-lead-target";
|
|
130
|
+
return {
|
|
131
|
+
schema: CHANNEL_CANDIDATE_DECISION_SCHEMA,
|
|
132
|
+
eligible: false,
|
|
133
|
+
reason,
|
|
134
|
+
repository,
|
|
135
|
+
source: { branch: sourceBranch, sha: sourceSha },
|
|
136
|
+
target: { branch: targetBranch, sha: targetSha },
|
|
137
|
+
comparison: {
|
|
138
|
+
status: String(comparison.status || "unknown"),
|
|
139
|
+
aheadBy: Number(comparison.aheadBy || 0),
|
|
140
|
+
},
|
|
141
|
+
decidedAt: now,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const rows = Array.isArray(input.workflowEvidence)
|
|
145
|
+
? input.workflowEvidence
|
|
146
|
+
: [];
|
|
147
|
+
const expectedPaths = [
|
|
148
|
+
...new Set((input.requiredWorkflowPaths || []).map(String)),
|
|
149
|
+
];
|
|
150
|
+
if (expectedPaths.length === 0)
|
|
151
|
+
throw new Error("requiredWorkflowPaths must not be empty");
|
|
152
|
+
if (rows.length !== expectedPaths.length) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`expected exactly ${expectedPaths.length} workflow evidence rows, got ${rows.length}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const byPath = new Map();
|
|
158
|
+
for (const row of rows) {
|
|
159
|
+
const qualified = qualifyWorkflow(row, { sourceSha, now, maxAgeSeconds });
|
|
160
|
+
if (byPath.has(qualified.workflowPath)) {
|
|
161
|
+
throw new Error(`duplicate workflow evidence: ${qualified.workflowPath}`);
|
|
162
|
+
}
|
|
163
|
+
byPath.set(qualified.workflowPath, qualified);
|
|
164
|
+
}
|
|
165
|
+
for (const workflowPath of expectedPaths) {
|
|
166
|
+
if (!byPath.has(workflowPath))
|
|
167
|
+
throw new Error(`missing workflow evidence: ${workflowPath}`);
|
|
168
|
+
}
|
|
169
|
+
const body = {
|
|
170
|
+
schema: CHANNEL_CANDIDATE_DECISION_SCHEMA,
|
|
171
|
+
eligible: true,
|
|
172
|
+
reason: "same-source-qualified",
|
|
173
|
+
repository,
|
|
174
|
+
source: { branch: sourceBranch, sha: sourceSha },
|
|
175
|
+
target: { branch: targetBranch, sha: targetSha },
|
|
176
|
+
comparison: { status: "ahead", aheadBy: Number(comparison.aheadBy) },
|
|
177
|
+
...(selection ? { selection } : {}),
|
|
178
|
+
sourceLockRef: channelCandidateSourceLockRef(targetBranch, sourceSha),
|
|
179
|
+
workflowEvidence: expectedPaths.map((workflowPath) =>
|
|
180
|
+
byPath.get(workflowPath),
|
|
181
|
+
),
|
|
182
|
+
policy: { maxAgeSeconds, requiredWorkflowPaths: expectedPaths },
|
|
183
|
+
decidedAt: now,
|
|
184
|
+
};
|
|
185
|
+
return { ...body, decisionRoot: root(body) };
|
|
186
|
+
}
|