@kungfu-tech/buildchain 2.12.7-alpha.1 → 2.12.7-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/actions/promote-buildchain-ref/README.md +9 -1
- package/dist/site/buildchain-contract.json +48 -23
- package/dist/site/buildchain-site.json +16 -16
- package/dist/site/capability-registry.json +1 -1
- package/dist/site/controller-registry.json +23 -3
- package/dist/site/kfd-claims.json +60 -8
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +2 -2
- package/dist/site/node-api-registry.json +6 -6
- package/dist/site/page-registry.json +10 -10
- package/dist/site/public-surface-audit.json +69 -9
- package/dist/site/publication-authority-registry.json +25 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +6 -6
- package/dist/site/workflow-registry.json +69 -6
- package/docs/publication-artifacts.md +25 -20
- package/docs/publication-authority.md +11 -2
- package/docs/release-governance.md +3 -1
- package/docs/shifu-gate-profiles.md +6 -0
- package/package.json +1 -1
- package/packages/core/buildchain-publication-authority.js +1 -0
- package/packages/core/controller-evidence.js +1 -1
- package/packages/core/index.js +7 -0
- package/packages/core/publication-artifact-candidate.js +128 -0
- package/packages/core/publication-authority.js +41 -2
- package/packages/core/publication-control-plane-audit.js +3 -1
- package/scripts/assemble-publication-artifact-admission.mjs +190 -0
- package/scripts/assemble-self-publication-admission.mjs +2 -1
- package/scripts/audit-publication-control-plane.mjs +5 -2
- package/scripts/check-inventory.mjs +5 -0
- package/scripts/publication-artifact-candidate.mjs +130 -0
- package/scripts/workflow-friction-report.mjs +13 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { validateControllerReceipt } from "./controller-evidence.js";
|
|
4
|
+
|
|
5
|
+
export const PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT =
|
|
6
|
+
"kungfu-buildchain-publication-artifact-candidate";
|
|
7
|
+
|
|
8
|
+
const MANIFEST_CONTRACT = "kungfu-buildchain-publication-artifact-manifest";
|
|
9
|
+
const PASSPORT_CONTRACT = "kungfu-buildchain-publication-artifact-passport";
|
|
10
|
+
|
|
11
|
+
function stableJson(value) {
|
|
12
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
13
|
+
if (value && typeof value === "object") {
|
|
14
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
15
|
+
}
|
|
16
|
+
return JSON.stringify(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function publicationArtifactCandidateDigest(value) {
|
|
20
|
+
return crypto.createHash("sha256").update(stableJson(value)).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function resolvePublicationCandidateFile(files = [], candidatePath) {
|
|
24
|
+
const normalizedPath = requiredString(candidatePath, "candidatePath").replaceAll("\\", "/");
|
|
25
|
+
if (normalizedPath.startsWith("/") || normalizedPath.split("/").includes("..")) {
|
|
26
|
+
throw new Error(`publication artifact candidate contains an unsafe path: ${normalizedPath}`);
|
|
27
|
+
}
|
|
28
|
+
const matches = files.filter((entry) => entry.path === normalizedPath);
|
|
29
|
+
if (matches.length !== 1) {
|
|
30
|
+
throw new Error(`expected exactly one publication candidate file at ${normalizedPath}, found ${matches.length}`);
|
|
31
|
+
}
|
|
32
|
+
return matches[0].path;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requiredString(value, label) {
|
|
36
|
+
const normalized = String(value || "").trim();
|
|
37
|
+
if (!normalized) throw new Error(`${label} must be a non-empty string`);
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function normalizeDigest(value, label) {
|
|
42
|
+
const normalized = requiredString(value, label).replace(/^sha256:/, "").toLowerCase();
|
|
43
|
+
if (!/^[0-9a-f]{64}$/.test(normalized)) throw new Error(`${label} must be a sha256 digest`);
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeGitSha(value, label) {
|
|
48
|
+
const normalized = requiredString(value, label).toLowerCase();
|
|
49
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(normalized)) {
|
|
50
|
+
throw new Error(`${label} must be a 40- or 64-character Git SHA`);
|
|
51
|
+
}
|
|
52
|
+
return normalized;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createPublicationArtifactCandidate({
|
|
56
|
+
repository,
|
|
57
|
+
sourceSha,
|
|
58
|
+
sourceTreeSha,
|
|
59
|
+
runtimeSha,
|
|
60
|
+
manifest,
|
|
61
|
+
passport,
|
|
62
|
+
controllerReceipt,
|
|
63
|
+
files = [],
|
|
64
|
+
} = {}) {
|
|
65
|
+
const normalizedRepository = requiredString(repository, "repository");
|
|
66
|
+
const normalizedSourceSha = normalizeGitSha(sourceSha, "sourceSha");
|
|
67
|
+
const normalizedSourceTreeSha = normalizeGitSha(sourceTreeSha, "sourceTreeSha");
|
|
68
|
+
const normalizedRuntimeSha = normalizeGitSha(runtimeSha, "runtimeSha");
|
|
69
|
+
if (manifest?.contract !== MANIFEST_CONTRACT) throw new Error("publication artifact manifest contract mismatch");
|
|
70
|
+
if (passport?.contract !== PASSPORT_CONTRACT || passport.status !== "passed") {
|
|
71
|
+
throw new Error("publication artifact passport is not qualifying");
|
|
72
|
+
}
|
|
73
|
+
if (manifest.source?.sha !== normalizedSourceSha || passport.source?.sha !== normalizedSourceSha) {
|
|
74
|
+
throw new Error("publication artifact source SHA mismatch");
|
|
75
|
+
}
|
|
76
|
+
if (manifest.source?.treeSha !== normalizedSourceTreeSha || passport.source?.treeSha !== normalizedSourceTreeSha) {
|
|
77
|
+
throw new Error("publication artifact source tree mismatch");
|
|
78
|
+
}
|
|
79
|
+
const manifestDigest = crypto.createHash("sha256").update(JSON.stringify(manifest, null, 2)).digest("hex");
|
|
80
|
+
if (normalizeDigest(passport.manifestDigest, "passport.manifestDigest") !== manifestDigest) {
|
|
81
|
+
throw new Error("publication artifact passport manifest digest mismatch");
|
|
82
|
+
}
|
|
83
|
+
const controllerValidation = validateControllerReceipt(controllerReceipt, {
|
|
84
|
+
expectedSourceSha: normalizedSourceSha,
|
|
85
|
+
expectedRuntimeSha: normalizedRuntimeSha,
|
|
86
|
+
});
|
|
87
|
+
if (!controllerValidation.ok || !controllerValidation.qualifying) {
|
|
88
|
+
throw new Error(`publication artifact controller receipt did not qualify: ${controllerValidation.issues.join("; ")}`);
|
|
89
|
+
}
|
|
90
|
+
const evidenceKinds = new Set((controllerReceipt.evidence || []).map((entry) => entry.kind));
|
|
91
|
+
for (const kind of ["publication-manifest", "publication-passport"]) {
|
|
92
|
+
if (!evidenceKinds.has(kind)) throw new Error(`publication artifact controller receipt is missing ${kind} evidence`);
|
|
93
|
+
}
|
|
94
|
+
const normalizedFiles = files.map((entry, index) => {
|
|
95
|
+
const filePath = requiredString(entry.path, `files[${index}].path`).replaceAll("\\", "/");
|
|
96
|
+
if (filePath.startsWith("/") || filePath.split("/").includes("..")) {
|
|
97
|
+
throw new Error(`publication artifact candidate contains an unsafe path: ${filePath}`);
|
|
98
|
+
}
|
|
99
|
+
const size = Number(entry.size ?? entry.bytes);
|
|
100
|
+
if (!Number.isSafeInteger(size) || size < 0) throw new Error(`files[${index}].size must be a non-negative safe integer`);
|
|
101
|
+
return { path: filePath, size, sha256: normalizeDigest(entry.sha256, `files[${index}].sha256`) };
|
|
102
|
+
}).sort((left, right) => left.path.localeCompare(right.path));
|
|
103
|
+
if (new Set(normalizedFiles.map((entry) => entry.path)).size !== normalizedFiles.length) {
|
|
104
|
+
throw new Error("publication artifact candidate file paths must be unique");
|
|
105
|
+
}
|
|
106
|
+
const byPath = new Map(normalizedFiles.map((entry) => [entry.path, entry]));
|
|
107
|
+
for (const [index, artifact] of (manifest.artifacts || []).entries()) {
|
|
108
|
+
const artifactPath = requiredString(artifact.path, `manifest.artifacts[${index}].path`);
|
|
109
|
+
const actual = byPath.get(artifactPath);
|
|
110
|
+
if (!actual) throw new Error(`publication artifact candidate is missing declared artifact: ${artifactPath}`);
|
|
111
|
+
if (actual.size !== Number(artifact.bytes) || actual.sha256 !== normalizeDigest(artifact.sha256, `manifest.artifacts[${index}].sha256`)) {
|
|
112
|
+
throw new Error(`publication artifact candidate bytes do not match manifest: ${artifactPath}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const payload = {
|
|
116
|
+
schemaVersion: 1,
|
|
117
|
+
contract: PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT,
|
|
118
|
+
repository: normalizedRepository,
|
|
119
|
+
sourceSha: normalizedSourceSha,
|
|
120
|
+
sourceTreeSha: normalizedSourceTreeSha,
|
|
121
|
+
runtimeSha: normalizedRuntimeSha,
|
|
122
|
+
manifestDigest,
|
|
123
|
+
passportDigest: publicationArtifactCandidateDigest(passport),
|
|
124
|
+
controllerReceiptDigest: normalizeDigest(controllerReceipt.digest, "controllerReceipt.digest"),
|
|
125
|
+
files: normalizedFiles,
|
|
126
|
+
};
|
|
127
|
+
return { ...payload, candidateDigest: publicationArtifactCandidateDigest(payload) };
|
|
128
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
3
|
import { validateControllerReceipt } from "./controller-evidence.js";
|
|
4
|
+
import { createPublicationArtifactCandidate } from "./publication-artifact-candidate.js";
|
|
4
5
|
import { sha256Json, validateReleaseCandidatePassport } from "./release-candidate.js";
|
|
5
6
|
|
|
6
7
|
export const PUBLICATION_AUTHORITY_REGISTRY_CONTRACT =
|
|
@@ -381,6 +382,7 @@ export function createPublicationArtifactManifestSet({
|
|
|
381
382
|
}
|
|
382
383
|
|
|
383
384
|
function validateGateAggregate(gateAggregate, { admission, passport }) {
|
|
385
|
+
const passportSourceSha = passport.source?.headSha || passport.source?.sha;
|
|
384
386
|
if (gateAggregate?.contract === "buildchain.shifu-gate-aggregate/v1") {
|
|
385
387
|
const { digest, ...payload } = gateAggregate;
|
|
386
388
|
const actualDigest = sha256Json(payload);
|
|
@@ -390,7 +392,7 @@ function validateGateAggregate(gateAggregate, { admission, passport }) {
|
|
|
390
392
|
if (gateAggregate.status !== "pass" || gateAggregate.qualifying !== true) {
|
|
391
393
|
throw new Error("gate aggregate is not qualifying");
|
|
392
394
|
}
|
|
393
|
-
if (![admission.sourceSha,
|
|
395
|
+
if (![admission.sourceSha, passportSourceSha].includes(gateAggregate.sourceSha)) {
|
|
394
396
|
throw new Error("gate aggregate source SHA mismatch");
|
|
395
397
|
}
|
|
396
398
|
normalizeDigest(gateAggregate.registry?.digest, "gateAggregate.registry.digest");
|
|
@@ -408,7 +410,7 @@ function validateGateAggregate(gateAggregate, { admission, passport }) {
|
|
|
408
410
|
if (gateAggregate.required !== false) {
|
|
409
411
|
throw new Error("a required Gate policy must supply a qualifying Shifu Gate aggregate");
|
|
410
412
|
}
|
|
411
|
-
if (![admission.sourceSha,
|
|
413
|
+
if (![admission.sourceSha, passportSourceSha].includes(gateAggregate.sourceSha)) {
|
|
412
414
|
throw new Error("publication Gate decision source SHA mismatch");
|
|
413
415
|
}
|
|
414
416
|
const normalizedPolicy = {
|
|
@@ -428,6 +430,43 @@ function validatePublicationEvidence(publicationEvidence, admission) {
|
|
|
428
430
|
if (!publicationEvidence || typeof publicationEvidence !== "object" || Array.isArray(publicationEvidence)) {
|
|
429
431
|
throw new Error("independent publication evidence is required");
|
|
430
432
|
}
|
|
433
|
+
if (publicationEvidence.publicationArtifactCandidate) {
|
|
434
|
+
const candidate = createPublicationArtifactCandidate(publicationEvidence.publicationArtifactCandidate);
|
|
435
|
+
const passport = publicationEvidence.publicationArtifactCandidate.passport;
|
|
436
|
+
const controllerReceipt = publicationEvidence.publicationArtifactCandidate.controllerReceipt;
|
|
437
|
+
const controllerReceiptDigest = normalizeDigest(controllerReceipt.digest, "controllerReceipt.digest");
|
|
438
|
+
if (controllerReceiptDigest !== normalizeDigest(admission.controllerReceiptDigest, "admission.controllerReceiptDigest")) {
|
|
439
|
+
throw new Error("controller receipt evidence binding mismatch");
|
|
440
|
+
}
|
|
441
|
+
const contractDigest = normalizeDigest(controllerReceipt.runtime?.contractDigest, "controllerReceipt.runtime.contractDigest");
|
|
442
|
+
if (contractDigest !== normalizeDigest(admission.contractDigest, "admission.contractDigest")) {
|
|
443
|
+
throw new Error("runtime contract evidence binding mismatch");
|
|
444
|
+
}
|
|
445
|
+
const gate = validateGateAggregate(publicationEvidence.gateAggregate, { admission, passport });
|
|
446
|
+
if (gate.gateAggregateDigest !== normalizeDigest(admission.gateAggregateDigest, "admission.gateAggregateDigest")) {
|
|
447
|
+
throw new Error("Gate aggregate evidence binding mismatch");
|
|
448
|
+
}
|
|
449
|
+
if (gate.policyDigest !== normalizeDigest(admission.policyDigest, "admission.policyDigest")) {
|
|
450
|
+
throw new Error("Gate policy evidence binding mismatch");
|
|
451
|
+
}
|
|
452
|
+
if (candidate.candidateDigest !== normalizeDigest(admission.artifactDigest, "admission.artifactDigest")) {
|
|
453
|
+
throw new Error("publication artifact candidate evidence binding mismatch");
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
sourceTreeSha: candidate.sourceTreeSha,
|
|
457
|
+
controllerReceiptDigest,
|
|
458
|
+
contractDigest,
|
|
459
|
+
gateAggregateDigest: gate.gateAggregateDigest,
|
|
460
|
+
policyDigest: gate.policyDigest,
|
|
461
|
+
artifactDigest: candidate.candidateDigest,
|
|
462
|
+
evidenceDigest: publicationAuthorityDigest({
|
|
463
|
+
sourceTreeSha: candidate.sourceTreeSha,
|
|
464
|
+
controllerReceiptDigest,
|
|
465
|
+
gateAggregateDigest: gate.gateAggregateDigest,
|
|
466
|
+
artifactDigest: candidate.candidateDigest,
|
|
467
|
+
}),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
431
470
|
const passport = publicationEvidence.releaseCandidatePassport;
|
|
432
471
|
const buildSummary = publicationEvidence.buildSummary;
|
|
433
472
|
const passportValidation = validateReleaseCandidatePassport({
|
|
@@ -16,6 +16,7 @@ export function evaluatePublicationControlPlaneSnapshot({
|
|
|
16
16
|
branch,
|
|
17
17
|
packageName,
|
|
18
18
|
publisherMode = "npm-trusted-publisher",
|
|
19
|
+
requiredStatusCheck = "check",
|
|
19
20
|
snapshot,
|
|
20
21
|
observedAt,
|
|
21
22
|
expiresAt,
|
|
@@ -72,7 +73,8 @@ export function evaluatePublicationControlPlaneSnapshot({
|
|
|
72
73
|
branchPolicy.protected === true &&
|
|
73
74
|
branchPolicy.enforcementLevel === "everyone" &&
|
|
74
75
|
Array.isArray(branchPolicy.requiredStatusChecks) &&
|
|
75
|
-
branchPolicy.
|
|
76
|
+
branchPolicy.requiredStatusCheck === requiredStatusCheck &&
|
|
77
|
+
branchPolicy.requiredStatusChecks.includes(requiredStatusCheck) &&
|
|
76
78
|
branchPolicy.requiredCheckPassed === true &&
|
|
77
79
|
branchPolicy.sourceSha === branchPolicy.headSha &&
|
|
78
80
|
branchPolicy.mergedPullRequest === true &&
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createPublicationAdmission,
|
|
9
|
+
createPublicationGateDecision,
|
|
10
|
+
createRunnerProvenance,
|
|
11
|
+
} from "../packages/core/publication-authority.js";
|
|
12
|
+
import { buildPublicationArtifactCandidate } from "./publication-artifact-candidate.mjs";
|
|
13
|
+
|
|
14
|
+
function required(name) {
|
|
15
|
+
const value = String(process.env[name] || "").trim();
|
|
16
|
+
if (!value) throw new Error(`${name} is required`);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sha256(value) {
|
|
21
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function sourceTree(repository, sourceSha, token) {
|
|
25
|
+
const response = await fetch(
|
|
26
|
+
`${required("GITHUB_API_URL")}/repos/${repository}/git/commits/${sourceSha}`,
|
|
27
|
+
{
|
|
28
|
+
headers: {
|
|
29
|
+
accept: "application/vnd.github+json",
|
|
30
|
+
authorization: `Bearer ${token}`,
|
|
31
|
+
"x-github-api-version": "2022-11-28",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
if (!response.ok)
|
|
36
|
+
throw new Error(
|
|
37
|
+
`could not resolve admitted source tree: GitHub API ${response.status}`,
|
|
38
|
+
);
|
|
39
|
+
const commit = await response.json();
|
|
40
|
+
return String(commit.tree?.sha || "").toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeBundle(outputDir, values) {
|
|
44
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
45
|
+
for (const [name, value] of Object.entries(values)) {
|
|
46
|
+
fs.writeFileSync(
|
|
47
|
+
path.join(outputDir, `${name}.json`),
|
|
48
|
+
`${JSON.stringify(value, null, 2)}\n`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
52
|
+
const output = fs.createWriteStream(process.env.GITHUB_OUTPUT, {
|
|
53
|
+
flags: "a",
|
|
54
|
+
});
|
|
55
|
+
for (const [name, value] of Object.entries(values)) {
|
|
56
|
+
if (name === "candidate") continue;
|
|
57
|
+
output.write(
|
|
58
|
+
`${name.replaceAll("_", "-")}-json=${JSON.stringify(value)}\n`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
output.end();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function main() {
|
|
66
|
+
const repository = required("BUILDCHAIN_REPOSITORY");
|
|
67
|
+
const sourceSha = required("BUILDCHAIN_SOURCE_SHA").toLowerCase();
|
|
68
|
+
const runtimeRoot =
|
|
69
|
+
process.env.BUILDCHAIN_RUNTIME_ROOT || ".buildchain/authority-runtime";
|
|
70
|
+
const runtimeSha = execFileSync(
|
|
71
|
+
"git",
|
|
72
|
+
["-C", runtimeRoot, "rev-parse", "HEAD"],
|
|
73
|
+
{ encoding: "utf8" },
|
|
74
|
+
)
|
|
75
|
+
.trim()
|
|
76
|
+
.toLowerCase();
|
|
77
|
+
const token = required("GITHUB_TOKEN");
|
|
78
|
+
const treeSha = await sourceTree(repository, sourceSha, token);
|
|
79
|
+
const candidateBundle = buildPublicationArtifactCandidate({
|
|
80
|
+
artifactRoot:
|
|
81
|
+
process.env.BUILDCHAIN_ARTIFACT_ROOT ||
|
|
82
|
+
".buildchain/publication-evidence/artifact",
|
|
83
|
+
controllerRoot:
|
|
84
|
+
process.env.BUILDCHAIN_CONTROLLER_ROOT ||
|
|
85
|
+
".buildchain/publication-evidence/controller",
|
|
86
|
+
repository,
|
|
87
|
+
sourceSha,
|
|
88
|
+
sourceTreeSha: treeSha,
|
|
89
|
+
runtimeSha,
|
|
90
|
+
});
|
|
91
|
+
const controllerReceipt = candidateBundle.evidence.controllerReceipt;
|
|
92
|
+
const controlPlaneAudit = JSON.parse(
|
|
93
|
+
fs.readFileSync(required("BUILDCHAIN_CONTROL_PLANE_AUDIT_PATH"), "utf8"),
|
|
94
|
+
);
|
|
95
|
+
const registry = JSON.parse(
|
|
96
|
+
fs.readFileSync(
|
|
97
|
+
path.join(runtimeRoot, "dist/site/publication-authority-registry.json"),
|
|
98
|
+
"utf8",
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
const gateAggregate = createPublicationGateDecision({
|
|
102
|
+
sourceSha,
|
|
103
|
+
profile: process.env.BUILDCHAIN_GATE_PROFILE || "managed-paper-publication",
|
|
104
|
+
required: false,
|
|
105
|
+
rationale:
|
|
106
|
+
process.env.BUILDCHAIN_GATE_RATIONALE ||
|
|
107
|
+
"The managed paper repository declares no project-specific Shifu Gate registry.",
|
|
108
|
+
policy: { scope: "managed-paper-publication", repository },
|
|
109
|
+
});
|
|
110
|
+
const runnerProvenance = createRunnerProvenance({
|
|
111
|
+
runnerClass: "ephemeral",
|
|
112
|
+
os: required("RUNNER_OS"),
|
|
113
|
+
architecture: required("RUNNER_ARCH"),
|
|
114
|
+
imageDigest: sha256(
|
|
115
|
+
`${process.env.ImageOS || "unknown"}|${process.env.ImageVersion || "unknown"}`,
|
|
116
|
+
),
|
|
117
|
+
measurementDigest: sha256(
|
|
118
|
+
[
|
|
119
|
+
process.env.GITHUB_WORKFLOW,
|
|
120
|
+
process.env.GITHUB_JOB,
|
|
121
|
+
process.env.GITHUB_RUN_ID,
|
|
122
|
+
process.env.GITHUB_RUN_ATTEMPT,
|
|
123
|
+
process.env.RUNNER_ENVIRONMENT,
|
|
124
|
+
].join("|"),
|
|
125
|
+
),
|
|
126
|
+
isolation: "github-hosted-single-job",
|
|
127
|
+
});
|
|
128
|
+
const issuedAt = new Date();
|
|
129
|
+
const admission = createPublicationAdmission({
|
|
130
|
+
registryDigest: registry.registryDigest,
|
|
131
|
+
workflowPath:
|
|
132
|
+
process.env.BUILDCHAIN_AUTHORITY_WORKFLOW_PATH ||
|
|
133
|
+
".github/workflows/paper-release-sealed.yml",
|
|
134
|
+
publisherWorkflowPath: required("BUILDCHAIN_PUBLISHER_WORKFLOW_PATH"),
|
|
135
|
+
repository,
|
|
136
|
+
sourceSha,
|
|
137
|
+
runtimeSha,
|
|
138
|
+
contractDigest: controllerReceipt.runtime?.contractDigest,
|
|
139
|
+
policyDigest: gateAggregate.policyDigest,
|
|
140
|
+
controllerReceiptDigest: controllerReceipt.digest,
|
|
141
|
+
runnerProvenanceDigest: runnerProvenance.receiptDigest,
|
|
142
|
+
controlPlaneAuditDigest: controlPlaneAudit.receiptDigest,
|
|
143
|
+
gateAggregateDigest: gateAggregate.digest,
|
|
144
|
+
environment: "none",
|
|
145
|
+
product: required("BUILDCHAIN_PUBLICATION_PRODUCT"),
|
|
146
|
+
target: required("BUILDCHAIN_PUBLICATION_TARGET"),
|
|
147
|
+
version: required("BUILDCHAIN_PUBLICATION_VERSION"),
|
|
148
|
+
channel: required("BUILDCHAIN_PUBLICATION_CHANNEL"),
|
|
149
|
+
artifactDigest: candidateBundle.candidate.candidateDigest,
|
|
150
|
+
nonce: `${required("GITHUB_RUN_ID")}:${required("GITHUB_RUN_ATTEMPT")}:${sourceSha}:paper`,
|
|
151
|
+
issuedAt: issuedAt.toISOString(),
|
|
152
|
+
expiresAt: new Date(issuedAt.getTime() + 10 * 60 * 1000).toISOString(),
|
|
153
|
+
});
|
|
154
|
+
const bindingNames = [
|
|
155
|
+
"repository",
|
|
156
|
+
"publisherWorkflowPath",
|
|
157
|
+
"sourceSha",
|
|
158
|
+
"runtimeSha",
|
|
159
|
+
"contractDigest",
|
|
160
|
+
"policyDigest",
|
|
161
|
+
"controllerReceiptDigest",
|
|
162
|
+
"gateAggregateDigest",
|
|
163
|
+
"environment",
|
|
164
|
+
"product",
|
|
165
|
+
"target",
|
|
166
|
+
"version",
|
|
167
|
+
"channel",
|
|
168
|
+
"artifactDigest",
|
|
169
|
+
];
|
|
170
|
+
const expected = Object.fromEntries(
|
|
171
|
+
bindingNames.map((name) => [name, admission[name]]),
|
|
172
|
+
);
|
|
173
|
+
writeBundle(
|
|
174
|
+
process.env.BUILDCHAIN_OUTPUT_DIR ||
|
|
175
|
+
".buildchain/publication-authority/auto",
|
|
176
|
+
{
|
|
177
|
+
admission,
|
|
178
|
+
runner_provenance: runnerProvenance,
|
|
179
|
+
control_plane_audit: controlPlaneAudit,
|
|
180
|
+
gate_aggregate: gateAggregate,
|
|
181
|
+
expected,
|
|
182
|
+
candidate: candidateBundle.candidate,
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
main().catch((error) => {
|
|
188
|
+
console.error(`assemble publication artifact admission: ${error.message}`);
|
|
189
|
+
process.exitCode = 1;
|
|
190
|
+
});
|
|
@@ -137,6 +137,7 @@ async function main() {
|
|
|
137
137
|
isolation: "github-hosted-single-job",
|
|
138
138
|
});
|
|
139
139
|
const packageJson = JSON.parse(fs.readFileSync(path.join(runtimeRoot, "package.json"), "utf8"));
|
|
140
|
+
const publicationVersion = required("BUILDCHAIN_PUBLICATION_VERSION");
|
|
140
141
|
const issuedAt = new Date();
|
|
141
142
|
const admission = createPublicationAdmission({
|
|
142
143
|
registryDigest: registry.registryDigest,
|
|
@@ -154,7 +155,7 @@ async function main() {
|
|
|
154
155
|
environment: process.env.BUILDCHAIN_PUBLICATION_ENVIRONMENT || "none",
|
|
155
156
|
product: process.env.BUILDCHAIN_PUBLICATION_PRODUCT || "Buildchain",
|
|
156
157
|
target: process.env.BUILDCHAIN_PUBLICATION_TARGET || `npm:${packageJson.name}`,
|
|
157
|
-
version:
|
|
158
|
+
version: publicationVersion,
|
|
158
159
|
channel: required("BUILDCHAIN_PUBLICATION_CHANNEL"),
|
|
159
160
|
artifactDigest: artifactSet.manifestSetDigest,
|
|
160
161
|
nonce: `${required("GITHUB_RUN_ID")}:${required("GITHUB_RUN_ATTEMPT")}:${sourceSha}`,
|
|
@@ -191,6 +191,7 @@ function main() {
|
|
|
191
191
|
const workflowPath = flag("workflow", ".github/workflows/release-candidate-promote.yml");
|
|
192
192
|
const workflowRef = flag("workflow-ref");
|
|
193
193
|
const publisherWorkflowPath = flag("publisher-workflow", workflowPath);
|
|
194
|
+
const requiredStatusCheck = flag("required-status-check", "check");
|
|
194
195
|
const jobId = flag("job", "promote");
|
|
195
196
|
const environment = flag("environment", "none");
|
|
196
197
|
const providerEnvironment = environment === "none" ? "" : environment;
|
|
@@ -280,15 +281,16 @@ function main() {
|
|
|
280
281
|
const checkRuns = githubJson(`repos/${repository}/commits/${sourceSha}/check-runs?per_page=100`, "source check runs");
|
|
281
282
|
const requiredStatusCheckPolicy = branchState.protection?.required_status_checks || {};
|
|
282
283
|
const requiredStatusChecks = requiredStatusCheckPolicy.contexts || [];
|
|
283
|
-
const requiredCheckSource = (requiredStatusCheckPolicy.checks || []).find((entry) => entry.context ===
|
|
284
|
+
const requiredCheckSource = (requiredStatusCheckPolicy.checks || []).find((entry) => entry.context === requiredStatusCheck);
|
|
284
285
|
branchPolicy = {
|
|
285
286
|
ref: branch,
|
|
286
287
|
policyMode: "provider-enforced-transaction",
|
|
287
288
|
protected: branchState.protected === true,
|
|
288
289
|
enforcementLevel: branchState.protection?.required_status_checks?.enforcement_level || "",
|
|
289
290
|
requiredStatusChecks,
|
|
291
|
+
requiredStatusCheck,
|
|
290
292
|
requiredCheckPassed: (checkRuns.check_runs || []).some((entry) =>
|
|
291
|
-
entry.name ===
|
|
293
|
+
entry.name === requiredStatusCheck &&
|
|
292
294
|
entry.conclusion === "success" &&
|
|
293
295
|
(!requiredCheckSource?.app_id || entry.app?.id === requiredCheckSource.app_id)
|
|
294
296
|
),
|
|
@@ -350,6 +352,7 @@ function main() {
|
|
|
350
352
|
branch,
|
|
351
353
|
packageName,
|
|
352
354
|
publisherMode,
|
|
355
|
+
requiredStatusCheck,
|
|
353
356
|
observedAt: observedAt.toISOString(),
|
|
354
357
|
expiresAt: expiresAt.toISOString(),
|
|
355
358
|
snapshot: {
|
|
@@ -840,6 +840,11 @@ for (const requiredSnippet of [
|
|
|
840
840
|
"publish-source-ref: ${{ steps.publish-gate.outputs.ref }}",
|
|
841
841
|
"publish-source-sha: ${{ steps.publish-gate.outputs.sha }}",
|
|
842
842
|
"publish-source-locked: ${{ steps.publish-gate.outputs.locked }}",
|
|
843
|
+
"expected-publication-version: ${{ needs.publication-plan.outputs.version }}",
|
|
844
|
+
"publication-version: ${{ needs.publication-plan.outputs.version }}",
|
|
845
|
+
"name: Plan exact publication version",
|
|
846
|
+
"DRY_RUN: ${{ inputs.dry-run }}",
|
|
847
|
+
"if (dryRun) {",
|
|
843
848
|
"Enforce Buildchain stable release canary gate",
|
|
844
849
|
"BUILDCHAIN_STABLE_RELEASE_POLICY: .buildchain/stable-release-policy.json",
|
|
845
850
|
]) {
|
|
@@ -0,0 +1,130 @@
|
|
|
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 {
|
|
8
|
+
createPublicationArtifactCandidate,
|
|
9
|
+
resolvePublicationCandidateFile,
|
|
10
|
+
} from "../packages/core/publication-artifact-candidate.js";
|
|
11
|
+
|
|
12
|
+
export { resolvePublicationCandidateFile };
|
|
13
|
+
|
|
14
|
+
function flag(name, fallback = "") {
|
|
15
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
16
|
+
return index === -1 ? fallback : String(process.argv[index + 1] || "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function requiredFlag(name) {
|
|
20
|
+
const value = flag(name).trim();
|
|
21
|
+
if (!value) throw new Error(`--${name} is required`);
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function filesNamed(root, name) {
|
|
26
|
+
const matches = [];
|
|
27
|
+
const pending = [path.resolve(root)];
|
|
28
|
+
while (pending.length > 0) {
|
|
29
|
+
const current = pending.pop();
|
|
30
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
31
|
+
const full = path.join(current, entry.name);
|
|
32
|
+
if (entry.isDirectory()) pending.push(full);
|
|
33
|
+
else if (entry.isFile() && entry.name === name) matches.push(full);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return matches.sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function oneJson(root, name) {
|
|
40
|
+
const matches = filesNamed(root, name);
|
|
41
|
+
if (matches.length !== 1)
|
|
42
|
+
throw new Error(
|
|
43
|
+
`expected exactly one ${name} under ${root}, found ${matches.length}`,
|
|
44
|
+
);
|
|
45
|
+
return JSON.parse(fs.readFileSync(matches[0], "utf8"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function collectFiles(root) {
|
|
49
|
+
const absoluteRoot = path.resolve(root);
|
|
50
|
+
const files = [];
|
|
51
|
+
const pending = [absoluteRoot];
|
|
52
|
+
while (pending.length > 0) {
|
|
53
|
+
const current = pending.pop();
|
|
54
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
55
|
+
const full = path.join(current, entry.name);
|
|
56
|
+
if (entry.isDirectory()) pending.push(full);
|
|
57
|
+
else if (entry.isFile()) {
|
|
58
|
+
files.push({
|
|
59
|
+
path: path.relative(absoluteRoot, full).split(path.sep).join("/"),
|
|
60
|
+
size: fs.statSync(full).size,
|
|
61
|
+
sha256: crypto
|
|
62
|
+
.createHash("sha256")
|
|
63
|
+
.update(fs.readFileSync(full))
|
|
64
|
+
.digest("hex"),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildPublicationArtifactCandidate({
|
|
73
|
+
artifactRoot,
|
|
74
|
+
controllerRoot,
|
|
75
|
+
repository,
|
|
76
|
+
sourceSha,
|
|
77
|
+
sourceTreeSha,
|
|
78
|
+
runtimeSha,
|
|
79
|
+
} = {}) {
|
|
80
|
+
const resolvedArtifactRoot = path.resolve(artifactRoot);
|
|
81
|
+
const resolvedControllerRoot = path.resolve(controllerRoot);
|
|
82
|
+
const evidence = {
|
|
83
|
+
repository,
|
|
84
|
+
sourceSha,
|
|
85
|
+
sourceTreeSha,
|
|
86
|
+
runtimeSha,
|
|
87
|
+
manifest: oneJson(resolvedArtifactRoot, "publication-artifact.json"),
|
|
88
|
+
passport: oneJson(
|
|
89
|
+
resolvedArtifactRoot,
|
|
90
|
+
"publication-artifact-passport.json",
|
|
91
|
+
),
|
|
92
|
+
controllerReceipt: oneJson(resolvedControllerRoot, "receipt.json"),
|
|
93
|
+
files: collectFiles(resolvedArtifactRoot),
|
|
94
|
+
};
|
|
95
|
+
const candidate = createPublicationArtifactCandidate(evidence);
|
|
96
|
+
return { schemaVersion: 1, candidate, evidence };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function main() {
|
|
100
|
+
const result = buildPublicationArtifactCandidate({
|
|
101
|
+
artifactRoot: requiredFlag("artifact-root"),
|
|
102
|
+
controllerRoot: requiredFlag("controller-root"),
|
|
103
|
+
repository: requiredFlag("repository"),
|
|
104
|
+
sourceSha: requiredFlag("source-sha"),
|
|
105
|
+
sourceTreeSha: requiredFlag("source-tree-sha"),
|
|
106
|
+
runtimeSha: requiredFlag("runtime-sha"),
|
|
107
|
+
});
|
|
108
|
+
const output = flag("output");
|
|
109
|
+
if (output) {
|
|
110
|
+
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
|
111
|
+
fs.writeFileSync(
|
|
112
|
+
path.resolve(output),
|
|
113
|
+
`${JSON.stringify(result, null, 2)}\n`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (process.argv.includes("--json") || !output)
|
|
117
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
process.argv[1] &&
|
|
122
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
123
|
+
) {
|
|
124
|
+
try {
|
|
125
|
+
main();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(`publication artifact candidate: ${error.message}`);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -202,6 +202,8 @@ export async function classifyWorkflowFriction({
|
|
|
202
202
|
buildWorkflowName = DEFAULT_BUILD_WORKFLOW_NAME,
|
|
203
203
|
releaseCandidateOutcome = env("BUILDCHAIN_RC_RESOLVE_OUTCOME"),
|
|
204
204
|
releaseCandidateDiagnosis = env("BUILDCHAIN_RC_DIAGNOSIS"),
|
|
205
|
+
promotionOutcome = env("BUILDCHAIN_PROMOTION_OUTCOME"),
|
|
206
|
+
promotionDiagnosis = env("BUILDCHAIN_PROMOTION_DIAGNOSIS"),
|
|
205
207
|
runUrl = env("BUILDCHAIN_WORKFLOW_RUN_URL"),
|
|
206
208
|
outputDir = ".buildchain/workflow-friction",
|
|
207
209
|
fetchImpl = globalThis.fetch,
|
|
@@ -256,14 +258,23 @@ export async function classifyWorkflowFriction({
|
|
|
256
258
|
if (releaseCandidateOutcome === "failure") {
|
|
257
259
|
diagnosisParts.push("Promotion reached the post-Verify workflow before required PR-stage RC evidence could be resolved.");
|
|
258
260
|
}
|
|
259
|
-
if (releaseCandidateDiagnosis) {
|
|
261
|
+
if (releaseCandidateOutcome === "failure" && releaseCandidateDiagnosis) {
|
|
260
262
|
diagnosisParts.push(releaseCandidateDiagnosis);
|
|
261
263
|
}
|
|
264
|
+
if (promotionOutcome === "failure") {
|
|
265
|
+
diagnosisParts.push(
|
|
266
|
+
promotionDiagnosis
|
|
267
|
+
? `Promotion failed: ${promotionDiagnosis}`
|
|
268
|
+
: "Promotion failed after PR-stage release-candidate evidence resolved successfully.",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
262
271
|
diagnosisParts.push(...workflowRunDiagnostics);
|
|
263
272
|
const diagnosis = diagnosisParts.join(" ") || "Buildchain ref promotion failed after Verify succeeded; inspect the classified evidence and keep the fix in Buildchain.";
|
|
264
273
|
const nextAction = frictionClass === "late-fail-fast"
|
|
265
274
|
? "Move the missing/stale RC evidence check earlier or make the promotion workflow consume the exact PR-stage RC passport before any publish side effect."
|
|
266
|
-
: "
|
|
275
|
+
: ["duplicate-channel-pr", "duplicate-heavy-build"].includes(frictionClass)
|
|
276
|
+
? "Deduplicate the PR/build path or tighten Buildchain workflow gates so the next channel promotion reaches publish exactly once."
|
|
277
|
+
: "Fix the concrete promotion failure above, then rerun through the protected channel workflow; do not treat successful RC resolution as the failure diagnosis.";
|
|
267
278
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
268
279
|
const bodyFile = path.join(outputDir, "issue-body.md");
|
|
269
280
|
const body = buildWorkflowFrictionBody({
|