@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.
Files changed (32) hide show
  1. package/actions/promote-buildchain-ref/README.md +9 -1
  2. package/dist/site/buildchain-contract.json +48 -23
  3. package/dist/site/buildchain-site.json +16 -16
  4. package/dist/site/capability-registry.json +1 -1
  5. package/dist/site/controller-registry.json +23 -3
  6. package/dist/site/kfd-claims.json +60 -8
  7. package/dist/site/kfd-upstream-aggregate.json +1 -1
  8. package/dist/site/manual-registry.json +2 -2
  9. package/dist/site/node-api-registry.json +6 -6
  10. package/dist/site/page-registry.json +10 -10
  11. package/dist/site/public-surface-audit.json +69 -9
  12. package/dist/site/publication-authority-registry.json +25 -1
  13. package/dist/site/publication-registry.json +4 -4
  14. package/dist/site/site-manifest.json +6 -6
  15. package/dist/site/workflow-registry.json +69 -6
  16. package/docs/publication-artifacts.md +25 -20
  17. package/docs/publication-authority.md +11 -2
  18. package/docs/release-governance.md +3 -1
  19. package/docs/shifu-gate-profiles.md +6 -0
  20. package/package.json +1 -1
  21. package/packages/core/buildchain-publication-authority.js +1 -0
  22. package/packages/core/controller-evidence.js +1 -1
  23. package/packages/core/index.js +7 -0
  24. package/packages/core/publication-artifact-candidate.js +128 -0
  25. package/packages/core/publication-authority.js +41 -2
  26. package/packages/core/publication-control-plane-audit.js +3 -1
  27. package/scripts/assemble-publication-artifact-admission.mjs +190 -0
  28. package/scripts/assemble-self-publication-admission.mjs +2 -1
  29. package/scripts/audit-publication-control-plane.mjs +5 -2
  30. package/scripts/check-inventory.mjs +5 -0
  31. package/scripts/publication-artifact-candidate.mjs +130 -0
  32. 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, passport.source?.headSha].includes(gateAggregate.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, passport.source?.headSha].includes(gateAggregate.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.requiredStatusChecks.includes("check") &&
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: process.env.BUILDCHAIN_PUBLICATION_VERSION || packageJson.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 === "check");
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 === "check" &&
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
- : "Deduplicate the PR/build path or tighten Buildchain workflow gates so the next channel promotion reaches publish exactly once.";
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({