@kungfu-tech/buildchain 2.12.7-alpha.2 → 2.12.7-alpha.21

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 (33) hide show
  1. package/actions/promote-buildchain-ref/README.md +5 -1
  2. package/dist/site/buildchain-contract.json +53 -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 +27 -3
  6. package/dist/site/kfd-claims.json +63 -9
  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 +74 -10
  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 +74 -7
  16. package/docs/publication-artifacts.md +30 -20
  17. package/docs/publication-authority.md +21 -9
  18. package/docs/release-candidate.md +19 -0
  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 +19 -9
  29. package/scripts/audit-publication-control-plane.mjs +5 -2
  30. package/scripts/check-inventory.mjs +3 -0
  31. package/scripts/locked-source-checkout.mjs +22 -2
  32. package/scripts/publication-artifact-candidate.mjs +122 -0
  33. package/scripts/workflow-friction-report.mjs +13 -2
@@ -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
+ });
@@ -115,13 +115,22 @@ async function main() {
115
115
  manifests,
116
116
  payloads: manifests.map((manifest) => payloadFor(manifest, path.join(evidenceRoot, "payloads"))),
117
117
  });
118
- const gateAggregate = createPublicationGateDecision({
119
- sourceSha,
120
- profile: process.env.BUILDCHAIN_GATE_PROFILE || "buildchain-self-publication",
121
- required: false,
122
- rationale: process.env.BUILDCHAIN_GATE_RATIONALE || "Buildchain self-publication has no consumer-owned Shifu Gate registry.",
123
- policy: { scope: "buildchain-self-publication", repository },
124
- });
118
+ const suppliedGateAggregate = String(process.env.BUILDCHAIN_GATE_AGGREGATE_JSON || "").trim();
119
+ let gateAggregate;
120
+ if (suppliedGateAggregate) {
121
+ gateAggregate = JSON.parse(suppliedGateAggregate);
122
+ } else {
123
+ if (process.env.BUILDCHAIN_ALLOW_NO_GATE !== "true") {
124
+ throw new Error("managed release-candidate admission requires a Gate aggregate or explicit no-Gate decision");
125
+ }
126
+ gateAggregate = createPublicationGateDecision({
127
+ sourceSha,
128
+ profile: process.env.BUILDCHAIN_GATE_PROFILE || "managed-release-candidate-no-gate",
129
+ required: false,
130
+ rationale: process.env.BUILDCHAIN_GATE_RATIONALE || "The consumer explicitly declared no Shifu Gate registry for this publication transaction.",
131
+ policy: { scope: "managed-release-candidate", repository },
132
+ });
133
+ }
125
134
  const runnerProvenance = createRunnerProvenance({
126
135
  runnerClass: "ephemeral",
127
136
  os: required("RUNNER_OS"),
@@ -137,6 +146,7 @@ async function main() {
137
146
  isolation: "github-hosted-single-job",
138
147
  });
139
148
  const packageJson = JSON.parse(fs.readFileSync(path.join(runtimeRoot, "package.json"), "utf8"));
149
+ const publicationVersion = required("BUILDCHAIN_PUBLICATION_VERSION");
140
150
  const issuedAt = new Date();
141
151
  const admission = createPublicationAdmission({
142
152
  registryDigest: registry.registryDigest,
@@ -154,7 +164,7 @@ async function main() {
154
164
  environment: process.env.BUILDCHAIN_PUBLICATION_ENVIRONMENT || "none",
155
165
  product: process.env.BUILDCHAIN_PUBLICATION_PRODUCT || "Buildchain",
156
166
  target: process.env.BUILDCHAIN_PUBLICATION_TARGET || `npm:${packageJson.name}`,
157
- version: process.env.BUILDCHAIN_PUBLICATION_VERSION || packageJson.version,
167
+ version: publicationVersion,
158
168
  channel: required("BUILDCHAIN_PUBLICATION_CHANNEL"),
159
169
  artifactDigest: artifactSet.manifestSetDigest,
160
170
  nonce: `${required("GITHUB_RUN_ID")}:${required("GITHUB_RUN_ATTEMPT")}:${sourceSha}`,
@@ -177,6 +187,6 @@ async function main() {
177
187
  }
178
188
 
179
189
  main().catch((error) => {
180
- console.error(`assemble self publication admission: ${error.message}`);
190
+ console.error(`assemble release-candidate admission: ${error.message}`);
181
191
  process.exitCode = 1;
182
192
  });
@@ -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,9 @@ 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",
843
846
  "DRY_RUN: ${{ inputs.dry-run }}",
844
847
  "if (dryRun) {",
845
848
  "Enforce Buildchain stable release canary gate",
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
7
 
8
8
  export const LOCKED_SOURCE_CHECKOUT_CONTRACT = "kungfu-buildchain-locked-source-checkout-cache";
9
+ export const ISOLATED_GIT_GLOBAL_CONFIG = process.platform === "win32" ? "NUL" : "/dev/null";
9
10
 
10
11
  const GIT_SHA_PATTERN = /^[0-9a-f]{40}$/i;
11
12
 
@@ -133,6 +134,24 @@ function githubAuthEnv(token = "") {
133
134
  };
134
135
  }
135
136
 
137
+ function isolatedGitFetchEnv(env = {}, targetPath) {
138
+ const configuredCount = Number.parseInt(env.GIT_CONFIG_COUNT || "0", 10);
139
+ const safeDirectoryIndex = Number.isInteger(configuredCount) && configuredCount >= 0
140
+ ? configuredCount
141
+ : 0;
142
+ return {
143
+ ...env,
144
+ // Runner-global URL rewrites are shared mutable state. A concurrent job
145
+ // may point the same repository URL at a different single-SHA bundle, so
146
+ // network fetches must not consult the account-level Git config.
147
+ GIT_CONFIG_GLOBAL: ISOLATED_GIT_GLOBAL_CONFIG,
148
+ GIT_CONFIG_NOSYSTEM: "1",
149
+ GIT_CONFIG_COUNT: String(safeDirectoryIndex + 1),
150
+ [`GIT_CONFIG_KEY_${safeDirectoryIndex}`]: "safe.directory",
151
+ [`GIT_CONFIG_VALUE_${safeDirectoryIndex}`]: path.resolve(targetPath),
152
+ };
153
+ }
154
+
136
155
  function markSafeDirectory(targetPath, timeoutMs) {
137
156
  try {
138
157
  git(["config", "--global", "--add", "safe.directory", path.resolve(targetPath)], {
@@ -206,6 +225,7 @@ export function fetchSourceCommit({
206
225
  runGit = git,
207
226
  containsCommit = hasCommit,
208
227
  }) {
228
+ const fetchEnv = isolatedGitFetchEnv(env, targetPath);
209
229
  try {
210
230
  runGit(["remote", "remove", remoteName], { cwd: targetPath, timeoutMs, stdio: "ignore" });
211
231
  } catch {
@@ -222,7 +242,7 @@ export function fetchSourceCommit({
222
242
  runGit(["fetch", "--no-tags", "--depth=1", remoteName, `+${fetchRef}:refs/buildchain/source-ref`], {
223
243
  cwd: targetPath,
224
244
  timeoutMs,
225
- env,
245
+ env: fetchEnv,
226
246
  });
227
247
  if (containsCommit(targetPath, sha, timeoutMs)) {
228
248
  return { selector: "ref", checkoutSha: sha };
@@ -252,7 +272,7 @@ export function fetchSourceCommit({
252
272
  runGit(["fetch", "--no-tags", "--depth=1", remoteName, `+${sha}:refs/buildchain/source`], {
253
273
  cwd: targetPath,
254
274
  timeoutMs,
255
- env,
275
+ env: fetchEnv,
256
276
  });
257
277
  if (!containsCommit(targetPath, sha, timeoutMs)) {
258
278
  throw new Error(`fetched ${fetchRef || sha}, but ${sha} is not available`);
@@ -0,0 +1,122 @@
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 exactJson(root, relativePath) {
26
+ const absoluteRoot = path.resolve(root);
27
+ const absolutePath = path.resolve(absoluteRoot, relativePath);
28
+ if (!absolutePath.startsWith(`${absoluteRoot}${path.sep}`)) {
29
+ throw new Error(`publication evidence path escapes artifact root: ${relativePath}`);
30
+ }
31
+ if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
32
+ throw new Error(`expected publication evidence at ${relativePath}`);
33
+ }
34
+ return JSON.parse(fs.readFileSync(absolutePath, "utf8"));
35
+ }
36
+
37
+ function collectFiles(root) {
38
+ const absoluteRoot = path.resolve(root);
39
+ const files = [];
40
+ const pending = [absoluteRoot];
41
+ while (pending.length > 0) {
42
+ const current = pending.pop();
43
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
44
+ const full = path.join(current, entry.name);
45
+ if (entry.isDirectory()) pending.push(full);
46
+ else if (entry.isFile()) {
47
+ files.push({
48
+ path: path.relative(absoluteRoot, full).split(path.sep).join("/"),
49
+ size: fs.statSync(full).size,
50
+ sha256: crypto
51
+ .createHash("sha256")
52
+ .update(fs.readFileSync(full))
53
+ .digest("hex"),
54
+ });
55
+ }
56
+ }
57
+ }
58
+ return files.sort((left, right) => left.path.localeCompare(right.path));
59
+ }
60
+
61
+ export function buildPublicationArtifactCandidate({
62
+ artifactRoot,
63
+ controllerRoot,
64
+ repository,
65
+ sourceSha,
66
+ sourceTreeSha,
67
+ runtimeSha,
68
+ } = {}) {
69
+ const resolvedArtifactRoot = path.resolve(artifactRoot);
70
+ const resolvedControllerRoot = path.resolve(controllerRoot);
71
+ const evidence = {
72
+ repository,
73
+ sourceSha,
74
+ sourceTreeSha,
75
+ runtimeSha,
76
+ manifest: exactJson(
77
+ resolvedArtifactRoot,
78
+ ".buildchain/publication/publication-artifact.json",
79
+ ),
80
+ passport: exactJson(
81
+ resolvedArtifactRoot,
82
+ ".buildchain/publication/publication-artifact-passport.json",
83
+ ),
84
+ controllerReceipt: exactJson(resolvedControllerRoot, "receipt.json"),
85
+ files: collectFiles(resolvedArtifactRoot),
86
+ };
87
+ const candidate = createPublicationArtifactCandidate(evidence);
88
+ return { schemaVersion: 1, candidate, evidence };
89
+ }
90
+
91
+ function main() {
92
+ const result = buildPublicationArtifactCandidate({
93
+ artifactRoot: requiredFlag("artifact-root"),
94
+ controllerRoot: requiredFlag("controller-root"),
95
+ repository: requiredFlag("repository"),
96
+ sourceSha: requiredFlag("source-sha"),
97
+ sourceTreeSha: requiredFlag("source-tree-sha"),
98
+ runtimeSha: requiredFlag("runtime-sha"),
99
+ });
100
+ const output = flag("output");
101
+ if (output) {
102
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
103
+ fs.writeFileSync(
104
+ path.resolve(output),
105
+ `${JSON.stringify(result, null, 2)}\n`,
106
+ );
107
+ }
108
+ if (process.argv.includes("--json") || !output)
109
+ process.stdout.write(`${JSON.stringify(result)}\n`);
110
+ }
111
+
112
+ if (
113
+ process.argv[1] &&
114
+ import.meta.url === pathToFileURL(process.argv[1]).href
115
+ ) {
116
+ try {
117
+ main();
118
+ } catch (error) {
119
+ console.error(`publication artifact candidate: ${error.message}`);
120
+ process.exitCode = 1;
121
+ }
122
+ }
@@ -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({