@kungfu-tech/buildchain 4.1.6-alpha.0 → 4.1.7-alpha.0

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 (101) hide show
  1. package/AGENTS.md +7 -5
  2. package/CONTRIBUTING.md +12 -6
  3. package/README.md +9 -5
  4. package/architecture/action-taxonomy.json +2 -0
  5. package/architecture/agent-change-map.md +6 -1
  6. package/architecture/build-orchestration.json +7 -1
  7. package/architecture/ci-lane-change-budget.json +1836 -0
  8. package/architecture/consumer-upgrade.json +6482 -0
  9. package/architecture/internal-capabilities.json +5 -2
  10. package/architecture/maintainability-debt.json +18 -10
  11. package/architecture/maintainability-policy.json +9 -9
  12. package/architecture/release-topology.json +10 -1
  13. package/architecture/universal-workflow-bootstrap.json +88 -18
  14. package/architecture/universal-workflow-capability-policy.json +2 -2
  15. package/architecture/workflow-taxonomy.json +2 -1
  16. package/contracts/dev-delivery-authority-v2.schema.json +29 -3
  17. package/contracts/fixtures/consumer-upgrade/build-v4.0.0.json +764 -0
  18. package/contracts/fixtures/consumer-upgrade/consumer-calls.json +447 -0
  19. package/contracts/fixtures/consumer-upgrade/dev-delivery-identity-v4.0.0.json +27 -0
  20. package/contracts/fixtures/consumer-upgrade/dev-pr-auto-merge-v4.0.0.json +256 -0
  21. package/contracts/fixtures/consumer-upgrade/dev-pr-auto-merge-v4.0.9.json +358 -0
  22. package/contracts/fixtures/consumer-upgrade/release-v4.0.0.json +815 -0
  23. package/contracts/promotion-invocation-v1.schema.json +5 -0
  24. package/dist/site/buildchain-contract.json +40 -23
  25. package/dist/site/buildchain-site.json +20 -20
  26. package/dist/site/capability-registry.json +5 -5
  27. package/dist/site/controller-registry.json +17 -5
  28. package/dist/site/kfd-claims.json +1376 -143
  29. package/dist/site/kfd-upstream-aggregate.json +1 -1
  30. package/dist/site/manual-registry.json +4 -4
  31. package/dist/site/node-api-registry.json +22 -22
  32. package/dist/site/page-registry.json +14 -14
  33. package/dist/site/public-surface-audit.json +1733 -101
  34. package/dist/site/publication-authority-registry.json +651 -32
  35. package/dist/site/publication-registry.json +1 -1
  36. package/dist/site/schemas/dev-delivery-authority-v2.schema.json +29 -3
  37. package/dist/site/site-manifest.json +5 -5
  38. package/dist/site/workflow-registry.json +2501 -149
  39. package/docs/MAP.md +5 -4
  40. package/docs/code-organization.md +11 -8
  41. package/docs/getting-started.md +31 -2
  42. package/docs/migration-inventory.md +8 -6
  43. package/docs/node-api-reference.md +36 -36
  44. package/docs/workflow-path-migration.md +8 -7
  45. package/package.json +1 -1
  46. package/packages/core/build/artifact/contracts.js +10 -2
  47. package/packages/core/build/artifact/transport.js +1 -1
  48. package/packages/core/build/artifact/upload.js +2 -2
  49. package/packages/core/build/plan/action.js +2 -0
  50. package/packages/core/build/plan/admission.js +9 -3
  51. package/packages/core/build/plan/compatibility-artifacts.js +47 -0
  52. package/packages/core/build/plan/compatibility-plan.js +121 -0
  53. package/packages/core/build/plan/compatibility.js +162 -0
  54. package/packages/core/build/plan/configuration.js +10 -37
  55. package/packages/core/build/plan/identity.js +42 -0
  56. package/packages/core/build/plan/lifecycle.js +1 -0
  57. package/packages/core/build/plan/resolve.js +2 -0
  58. package/packages/core/build/summary/aggregation.js +4 -2
  59. package/packages/core/build/summary/compatibility.js +24 -0
  60. package/packages/core/build/summary/finalization.js +18 -0
  61. package/packages/core/build/summary/historical-outputs.js +88 -0
  62. package/packages/core/contracts/runtime-contract-inspection.js +8 -0
  63. package/packages/core/dev-delivery/candidate/submission.js +2 -1
  64. package/packages/core/dev-delivery/commands/dev-delivery-authority-command-adapters.mjs +2 -1
  65. package/packages/core/dev-delivery/commands/dev-delivery-authority.mjs +6 -5
  66. package/packages/core/dev-delivery/commands/dev-delivery-warrant-options.mjs +22 -3
  67. package/packages/core/dev-delivery/dev-delivery-candidate-identity.js +13 -4
  68. package/packages/core/dev-delivery/dev-delivery-warrant.js +4 -1
  69. package/packages/core/dev-delivery/warrant/handoff.js +2 -1
  70. package/packages/core/dev-delivery/warrant/transitions.js +2 -2
  71. package/packages/core/governance/buildchain-publication-authority.js +5 -9
  72. package/packages/core/release/github-release-metadata.js +58 -0
  73. package/packages/core/release/promote-candidate/action.js +4 -2
  74. package/packages/core/release/promote-candidate/preparation.js +6 -0
  75. package/packages/core/release/promote-candidate/publication.js +36 -0
  76. package/packages/core/release/promote-candidate/release-documents.js +1 -1
  77. package/packages/core/release/promotion/candidate.js +4 -2
  78. package/packages/core/release/promotion/compatibility-context.js +93 -0
  79. package/packages/core/release/promotion/compatibility-qualification.js +48 -0
  80. package/packages/core/release/promotion/compatibility.js +141 -0
  81. package/packages/core/release/promotion/historical-evidence-action.js +17 -0
  82. package/packages/core/release/promotion/historical-evidence-files.js +112 -0
  83. package/packages/core/release/promotion/historical-evidence.js +231 -0
  84. package/packages/core/release/promotion/qualification.js +12 -1
  85. package/packages/core/release/promotion/routing.js +3 -2
  86. package/packages/core/release/promotion-request.js +31 -2
  87. package/packages/core/release/release-tail-provider-adapters.js +18 -0
  88. package/packages/core/runtime/entry/actions.js +4 -1
  89. package/packages/core/runtime/entry/compatibility.js +53 -0
  90. package/packages/core/workflow/engine/conformance.js +7 -3
  91. package/packages/core/workflow/pipeline/controller.js +26 -26
  92. package/scripts/check-dev-delivery-actions.mjs +12 -0
  93. package/scripts/check-inventory.mjs +1 -1
  94. package/scripts/check-public-dogfood-contract.mjs +2 -0
  95. package/scripts/check-release-topology.mjs +5 -1
  96. package/scripts/check-self-consumer-contract.mjs +5 -1
  97. package/scripts/generate-bootstrap-admission.mjs +20 -0
  98. package/scripts/generate-site-bundle.mjs +5 -5
  99. package/scripts/generate-workflow-taxonomy.mjs +17 -0
  100. package/scripts/maintainability-metrics.mjs +30 -2
  101. package/scripts/maintainability-public-surface.mjs +5 -3
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs";
2
+ import {
3
+ HISTORICAL_PROMOTION_SCHEMA,
4
+ historicalPromotionContext,
5
+ historicalEvidenceFileKeys,
6
+ } from "./compatibility-context.js";
7
+ export { HISTORICAL_PROMOTION_SCHEMA } from "./compatibility-context.js";
8
+ import path from "node:path";
9
+ import { installationRoot } from "../../runtime/installation-root.js";
10
+ import { normalizePromotionRequest } from "../promotion-request.js";
11
+
12
+ const root = installationRoot(import.meta.url);
13
+ const read = (relative) =>
14
+ JSON.parse(fs.readFileSync(path.join(root, relative), "utf8"));
15
+ let historical, current;
16
+ function loadHistoricalContract() {
17
+ historical ||= read(
18
+ "contracts/fixtures/consumer-upgrade/release-v4.0.0.json",
19
+ );
20
+ current ||= read("contracts/promotion-request-v1.schema.json");
21
+ }
22
+ const transports = new Set([
23
+ "request-json",
24
+ "runtime-ref",
25
+ "contract-lock",
26
+ "runtime-selection",
27
+ ]);
28
+ const selectors = new Set([
29
+ "buildchain-channel",
30
+ "buildchain-repository",
31
+ "buildchain-ref",
32
+ "buildchain-contract-lock-path",
33
+ "buildchain-alpha-contract-lock-path",
34
+ "buildchain-stable-contract-lock-path",
35
+ ]);
36
+ export const HISTORICAL_PROMOTION_ENVELOPE =
37
+ "buildchain.promotion-compatibility/v1";
38
+
39
+ function object(value, label) {
40
+ if (!value || typeof value !== "object" || Array.isArray(value))
41
+ throw new Error(`${label} must be an object`);
42
+ return value;
43
+ }
44
+
45
+ function normalizeInputs(value) {
46
+ const inputs = object(value, "Historical promotion inputs");
47
+ for (const key of Object.keys(inputs)) {
48
+ const field = historical.inputs[key];
49
+ if (!field && !transports.has(key))
50
+ throw new Error(`Unsupported historical promotion input: ${key}`);
51
+ const type = field?.type || "string";
52
+ if (
53
+ typeof inputs[key] !== type ||
54
+ (type === "number" && !Number.isFinite(inputs[key]))
55
+ )
56
+ throw new Error(`Historical promotion ${key} must be ${type}`);
57
+ }
58
+ return Object.fromEntries(
59
+ Object.entries(historical.inputs).map(([key, field]) => [
60
+ key,
61
+ inputs[key] ??
62
+ field.default ??
63
+ (field.type === "boolean" ? false : field.type === "number" ? 0 : ""),
64
+ ]),
65
+ );
66
+ }
67
+
68
+ // The envelope is emitted only by the retained workflow. Ordinary request JSON
69
+ // remains the existing closed typed API; it cannot opt into a historical contract.
70
+ export function normalizeHistoricalPromotion(value, workflowRef) {
71
+ const envelope = typeof value === "string" ? JSON.parse(value) : value;
72
+ if (envelope?.schema !== HISTORICAL_PROMOTION_ENVELOPE)
73
+ return { request: normalizePromotionRequest(value), historical: "" };
74
+ loadHistoricalContract();
75
+ object(envelope, "Historical promotion envelope");
76
+ if (Object.keys(envelope).some((key) => !["schema", "inputs"].includes(key)))
77
+ throw new Error("Unsupported historical promotion envelope field");
78
+ if (
79
+ workflowRef?.split("@")[0] !==
80
+ "kungfu-systems/buildchain/.github/workflows/release-candidate-promote.yml"
81
+ )
82
+ throw new Error(
83
+ "Historical promotion requires its retained workflow identity",
84
+ );
85
+ const inputs = normalizeInputs(envelope.inputs);
86
+ const supplied = envelope.inputs["request-json"];
87
+ if (supplied) {
88
+ if (
89
+ Object.entries(inputs).some(
90
+ ([key, value]) =>
91
+ value !== (historical.inputs[key].default ?? "") &&
92
+ !selectors.has(key),
93
+ )
94
+ )
95
+ throw new Error(
96
+ "Cannot combine request-json with historical promotion arguments",
97
+ );
98
+ const typed = normalizePromotionRequest(supplied);
99
+ return {
100
+ request: typed,
101
+ historical: JSON.stringify({
102
+ schema: HISTORICAL_PROMOTION_SCHEMA,
103
+ inputs: Object.fromEntries(
104
+ historicalEvidenceFileKeys
105
+ .filter((key) => typed[key])
106
+ .map((key) => [key, typed[key]]),
107
+ ),
108
+ }),
109
+ };
110
+ }
111
+ for (const key of [
112
+ "branch-protection-bypass-users",
113
+ "branch-protection-bypass-teams",
114
+ ])
115
+ if (inputs[key]) throw new Error(`Historical promotion rejects ${key}`);
116
+ const request = { schema: "buildchain.promotion-request/v1" };
117
+ const retained = {};
118
+ for (const [key, value] of Object.entries(inputs)) {
119
+ if (Object.hasOwn(current.properties, key)) request[key] = value;
120
+ else if (!selectors.has(key) && value !== "") retained[key] = value;
121
+ if (historicalEvidenceFileKeys.includes(key) && value)
122
+ retained[key] = value;
123
+ }
124
+ if (request["publish-mode"] === "publish-final-version") {
125
+ retained["publish-mode"] = "publish-final-version";
126
+ request["publish-mode"] = "";
127
+ }
128
+ if (request["publish-artifact-kind"] === "binary") {
129
+ retained["publish-artifact-kind"] = "binary";
130
+ request["publish-artifact-kind"] = "custom";
131
+ }
132
+ return {
133
+ request: normalizePromotionRequest(request),
134
+ historical: JSON.stringify(
135
+ historicalPromotionContext({
136
+ schema: HISTORICAL_PROMOTION_SCHEMA,
137
+ inputs: retained,
138
+ }),
139
+ ),
140
+ };
141
+ }
@@ -0,0 +1,17 @@
1
+ import path from "node:path";
2
+ import { verifyPromotionInvocation } from "../promotion-request.js";
3
+ import { qualifyHistoricalEvidence } from "./historical-evidence.js";
4
+ export function historicalEvidenceAction(core, env) {
5
+ const request = verifyPromotionInvocation(
6
+ core.getInput("request-json", { required: true }),
7
+ );
8
+ const workspace = path.resolve(env.GITHUB_WORKSPACE);
9
+ const receipt = qualifyHistoricalEvidence({
10
+ context: request["historical-inputs-json"],
11
+ sourceDirectory: path.join(workspace, ".buildchain/historical-source"),
12
+ candidateDirectory: path.join(workspace, ".buildchain/release-candidate"),
13
+ sourceSha: core.getInput("source-sha", { required: true }),
14
+ environment: env,
15
+ });
16
+ if (receipt) core.setOutput("receipt-root", receipt.receiptRoot);
17
+ }
@@ -0,0 +1,112 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ createKfd1ReleaseGateEvidence,
5
+ normalizeKfd1ContractWorldWitness,
6
+ createKfd3CollaborationInterfaceReleaseGateEvidence,
7
+ } from "../../adoption/kfd-gate.js";
8
+ import { createKfd2ReleaseTrustPassportAudit } from "../passport/trust-claims.js";
9
+ import { normalizeImpactLedger } from "../passport/impact.js";
10
+ import { historicalEvidenceFileKeys } from "./compatibility-context.js";
11
+
12
+ function containedFile(root, relative) {
13
+ const candidate = path.resolve(root, relative);
14
+ const boundary = `${fs.realpathSync(root)}${path.sep}`;
15
+ if (!candidate.startsWith(`${path.resolve(root)}${path.sep}`))
16
+ throw new Error("Historical evidence path escapes its declared directory");
17
+ if (!fs.existsSync(candidate)) return candidate;
18
+ if (!fs.realpathSync(candidate).startsWith(boundary))
19
+ throw new Error(
20
+ "Historical evidence symlink escapes its declared directory",
21
+ );
22
+ if (!fs.statSync(candidate).isFile())
23
+ throw new Error("Historical evidence requires a regular file");
24
+ return candidate;
25
+ }
26
+
27
+ function readValues(input, sourceDirectory) {
28
+ if (!input) return [];
29
+ const entries = /^[\s]*[\[{]/u.test(input)
30
+ ? [input]
31
+ : input
32
+ .split(/\r?\n/u)
33
+ .map((line) => line.trim())
34
+ .filter(Boolean);
35
+ return entries.flatMap((entry) => {
36
+ const value = JSON.parse(
37
+ /^[\s]*[\[{]/u.test(entry)
38
+ ? entry
39
+ : fs.readFileSync(containedFile(sourceDirectory, entry), "utf8"),
40
+ );
41
+ return Array.isArray(value) ? value : [value];
42
+ });
43
+ }
44
+
45
+ export function historicalEvidenceFiles(inputs, sourceDirectory) {
46
+ return Object.fromEntries(
47
+ historicalEvidenceFileKeys
48
+ .filter((key) => inputs[key])
49
+ .map((key) => [key, readValues(inputs[key], sourceDirectory)]),
50
+ );
51
+ }
52
+
53
+ export function qualifyHistoricalEvidenceFiles({
54
+ files,
55
+ results,
56
+ sourceDirectory,
57
+ candidateDirectory,
58
+ verifiedAt,
59
+ }) {
60
+ const [kfd1Key, kfd2Key, prebuildKey, artifactKey, impactKey] =
61
+ historicalEvidenceFileKeys;
62
+ const witnesses = files[kfd1Key] || [];
63
+ // The maintained KFD-1 resolver can use source surfaces or payload surfaces.
64
+ // Validate both roots before giving it consumer-declared paths.
65
+ for (const witness of witnesses.map((value) =>
66
+ normalizeKfd1ContractWorldWitness(value),
67
+ ))
68
+ for (const surface of witness.surfaces || []) {
69
+ if (surface.sourcePath)
70
+ containedFile(sourceDirectory, surface.sourcePath);
71
+ if (surface.artifactPath) {
72
+ containedFile(sourceDirectory, surface.artifactPath);
73
+ containedFile(
74
+ path.join(candidateDirectory, "payloads"),
75
+ surface.artifactPath,
76
+ );
77
+ }
78
+ }
79
+ const kfd1 = createKfd1ReleaseGateEvidence({
80
+ cwd: sourceDirectory,
81
+ artifactRoot: path.join(candidateDirectory, "payloads"),
82
+ witnesses,
83
+ verifiedAt,
84
+ })?.passportSection;
85
+ const commandWitness =
86
+ results["release-passport-kfd-3-artifact-verify-command"];
87
+ const kfd3 = createKfd3CollaborationInterfaceReleaseGateEvidence({
88
+ prebuildWitnesses: files[prebuildKey] || [],
89
+ artifactWitnesses: [
90
+ ...(files[artifactKey] || []),
91
+ ...(commandWitness ? [commandWitness] : []),
92
+ ],
93
+ verifiedAt,
94
+ })?.passportSection;
95
+ const kfd2 = createKfd2ReleaseTrustPassportAudit({
96
+ explicitClaims: files[kfd2Key] || [],
97
+ kfd1Section: kfd1,
98
+ kfd3Section: kfd3,
99
+ verifiedAt,
100
+ });
101
+ const gates = Object.fromEntries(
102
+ Object.entries({ kfd1, kfd3, kfd2 }).filter(([, value]) => value),
103
+ );
104
+ for (const [key, gate] of Object.entries(gates))
105
+ if (gate.status === "failed")
106
+ throw new Error(`Historical ${key} release evidence failed`);
107
+ if (files[impactKey])
108
+ gates.impact = files[impactKey].map((value) =>
109
+ normalizeImpactLedger(value),
110
+ );
111
+ return JSON.parse(JSON.stringify(gates));
112
+ }
@@ -0,0 +1,231 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import crypto from "node:crypto";
5
+ import { spawnSync, execFileSync } from "node:child_process";
6
+ import { releaseTailRoot } from "../release-tail-provider-plane.js";
7
+ import {
8
+ historicalEvidenceFiles,
9
+ qualifyHistoricalEvidenceFiles,
10
+ } from "./historical-evidence-files.js";
11
+ import {
12
+ historicalEvidenceFileKeys,
13
+ historicalPromotionContext,
14
+ } from "./compatibility-context.js";
15
+ import { normalizeKfd3CollaborationInterfaceArtifactWitness } from "../../adoption/kfd-gate.js";
16
+ import { createInvariantPassportGate } from "../passport/invariants.js";
17
+
18
+ const filename = "buildchain.historical-evidence.json";
19
+ export const historicalEvidenceKeys = [
20
+ "release-passport-kfd-3-artifact-verify-command",
21
+ "release-passport-invariant-passport-command",
22
+ ];
23
+ function evidenceCommands(value) {
24
+ const inputs = historicalPromotionContext(value)?.inputs || {};
25
+ return historicalEvidenceKeys
26
+ .filter((key) => inputs[key])
27
+ .map((key) => [key, inputs[key]]);
28
+ }
29
+ function needsEvidence(value) {
30
+ return (
31
+ evidenceCommands(value).length ||
32
+ historicalEvidenceFileKeys.some(
33
+ (key) => historicalPromotionContext(value)?.inputs[key],
34
+ )
35
+ );
36
+ }
37
+ export function historicalEvidencePaths(value, directory) {
38
+ return needsEvidence(value) ? [path.join(directory, filename)] : [];
39
+ }
40
+ function materialRoot(directory) {
41
+ if (
42
+ !fs.lstatSync(directory).isDirectory() ||
43
+ fs.lstatSync(directory).isSymbolicLink()
44
+ )
45
+ throw new Error(
46
+ "Historical evidence materials require a regular directory",
47
+ );
48
+ const files = [];
49
+ function visit(relative) {
50
+ for (const name of fs.readdirSync(path.join(directory, relative)).sort()) {
51
+ const file = path.join(relative, name);
52
+ if (file === filename) continue;
53
+ const stat = fs.lstatSync(path.join(directory, file));
54
+ if (stat.isDirectory()) visit(file);
55
+ else if (stat.isFile())
56
+ files.push({
57
+ path: file.split(path.sep).join("/"),
58
+ digest: crypto
59
+ .createHash("sha256")
60
+ .update(fs.readFileSync(path.join(directory, file)))
61
+ .digest("hex"),
62
+ });
63
+ else
64
+ throw new Error(
65
+ "Historical evidence materials must contain only regular files",
66
+ );
67
+ }
68
+ }
69
+ visit("");
70
+ return releaseTailRoot(files);
71
+ }
72
+ function runEvidenceCommand(key, command, sourceDirectory, environment) {
73
+ const result = spawnSync(command, [], {
74
+ cwd: sourceDirectory,
75
+ shell: true,
76
+ encoding: "utf8",
77
+ env: environment,
78
+ timeout: 300000,
79
+ maxBuffer: 16 * 1024 * 1024,
80
+ });
81
+ if (result.error || result.status !== 0)
82
+ throw new Error(
83
+ `Historical ${key} failed with status ${result.status ?? "unavailable"}`,
84
+ );
85
+ let value;
86
+ try {
87
+ value = JSON.parse(result.stdout);
88
+ } catch {
89
+ throw new Error(`Historical ${key} must emit valid JSON`);
90
+ }
91
+ if (key === historicalEvidenceKeys[0])
92
+ return normalizeKfd3CollaborationInterfaceArtifactWitness(value);
93
+ return createInvariantPassportGate([{ value }]);
94
+ }
95
+
96
+ export function qualifyHistoricalEvidence({
97
+ context,
98
+ sourceDirectory,
99
+ candidateDirectory,
100
+ sourceSha,
101
+ environment = process.env,
102
+ }) {
103
+ const commands = evidenceCommands(context);
104
+ if (!needsEvidence(context)) return null;
105
+ if (
106
+ !/^[a-f0-9]{40}$/u.test(sourceSha) ||
107
+ execFileSync("git", ["-C", sourceDirectory, "rev-parse", "HEAD"], {
108
+ encoding: "utf8",
109
+ }).trim() !== sourceSha
110
+ )
111
+ throw new Error(
112
+ "Historical evidence requires the exact qualified candidate checkout",
113
+ );
114
+ const files = historicalEvidenceFiles(
115
+ historicalPromotionContext(context).inputs,
116
+ sourceDirectory,
117
+ );
118
+ const verifiedAt = execFileSync(
119
+ "git",
120
+ ["-C", sourceDirectory, "show", "-s", "--format=%cI", sourceSha],
121
+ { encoding: "utf8" },
122
+ ).trim();
123
+ const before = materialRoot(candidateDirectory);
124
+ const consumerBuildchain = path.join(sourceDirectory, ".buildchain");
125
+ if (
126
+ fs.existsSync(consumerBuildchain) &&
127
+ fs.lstatSync(consumerBuildchain).isSymbolicLink()
128
+ )
129
+ throw new Error(
130
+ "Historical product configuration cannot escape its source checkout",
131
+ );
132
+ fs.mkdirSync(consumerBuildchain, { recursive: true });
133
+ // The old command path remains relative to its product source checkout.
134
+ const link = path.join(consumerBuildchain, "release-candidate");
135
+ if (fs.existsSync(link)) {
136
+ if (
137
+ !fs.lstatSync(link).isSymbolicLink() ||
138
+ fs.realpathSync(link) !== fs.realpathSync(candidateDirectory)
139
+ )
140
+ throw new Error(
141
+ "Historical candidate path is occupied by unrelated source content",
142
+ );
143
+ } else fs.symlinkSync(path.resolve(candidateDirectory), link, "dir");
144
+ const home = fs.mkdtempSync(
145
+ path.join(os.tmpdir(), "buildchain-historical-evidence-"),
146
+ );
147
+ const env = Object.fromEntries(
148
+ ["PATH", "TMPDIR", "SYSTEMROOT", "SystemRoot", "PATHEXT"]
149
+ .filter((key) => environment[key])
150
+ .map((key) => [key, environment[key]]),
151
+ );
152
+ Object.assign(env, {
153
+ HOME: home,
154
+ BUILDCHAIN_RELEASE_CANDIDATE_PAYLOADS: path.join(
155
+ candidateDirectory,
156
+ "payloads",
157
+ ),
158
+ });
159
+ let results;
160
+ try {
161
+ results = Object.fromEntries(
162
+ commands.map(([key, command]) => [
163
+ key,
164
+ runEvidenceCommand(key, command, sourceDirectory, env),
165
+ ]),
166
+ );
167
+ } finally {
168
+ fs.rmSync(home, { recursive: true, force: true });
169
+ }
170
+ if (materialRoot(candidateDirectory) !== before)
171
+ throw new Error(
172
+ "Historical evidence command changed qualified publication materials",
173
+ );
174
+ for (const passport of results[historicalEvidenceKeys[1]]?.passports || [])
175
+ if (passport.source.revision !== sourceSha)
176
+ throw new Error(
177
+ "Historical invariant passport must bind the qualified source revision",
178
+ );
179
+ const gates = qualifyHistoricalEvidenceFiles({
180
+ files,
181
+ results,
182
+ sourceDirectory,
183
+ candidateDirectory,
184
+ verifiedAt,
185
+ });
186
+ const body = {
187
+ files,
188
+ gates,
189
+ schema: "buildchain.historical-evidence/v1",
190
+ contextRoot: releaseTailRoot(historicalPromotionContext(context)),
191
+ sourceSha,
192
+ materialRoot: before,
193
+ results,
194
+ };
195
+ const receipt = { ...body, receiptRoot: releaseTailRoot(body) };
196
+ fs.writeFileSync(
197
+ path.join(candidateDirectory, filename),
198
+ `${JSON.stringify(receipt, null, 2)}\n`,
199
+ );
200
+ return receipt;
201
+ }
202
+
203
+ export function verifyHistoricalEvidence({
204
+ context,
205
+ candidateDirectory,
206
+ sourceSha,
207
+ }) {
208
+ if (!needsEvidence(context)) return null;
209
+ const { receiptRoot, ...body } = JSON.parse(
210
+ fs.readFileSync(path.join(candidateDirectory, filename), "utf8"),
211
+ );
212
+ if (
213
+ body.schema !== "buildchain.historical-evidence/v1" ||
214
+ receiptRoot !== releaseTailRoot(body) ||
215
+ body.contextRoot !== releaseTailRoot(historicalPromotionContext(context)) ||
216
+ body.sourceSha !== sourceSha ||
217
+ body.materialRoot !== materialRoot(candidateDirectory)
218
+ )
219
+ throw new Error(
220
+ "Historical evidence does not match the qualified candidate and invocation",
221
+ );
222
+ const keys = evidenceCommands(context)
223
+ .map(([key]) => key)
224
+ .sort();
225
+ if (
226
+ JSON.stringify(Object.keys(body.results || {}).sort()) !==
227
+ JSON.stringify(keys)
228
+ )
229
+ throw new Error("Historical evidence is missing required command results");
230
+ return { ...body, receiptRoot };
231
+ }
@@ -1,3 +1,6 @@
1
+ import { historicalEvidencePaths } from "./historical-evidence.js";
2
+ import { assertHistoricalPromotionEffects } from "./compatibility-qualification.js";
3
+ import { historicalReleaseMetadata } from "../github-release-metadata.js";
1
4
  import path from "node:path";
2
5
  import { assertDeclarativePromotionInputs } from "../../publication/publication-qualification.js";
3
6
  import { materializePublicationIntent } from "../candidate/publication-intent.js";
@@ -33,6 +36,7 @@ export async function qualifyPromotion(
33
36
  );
34
37
  if (intent.action !== "promote") return intent;
35
38
  assertDeclarativePromotionInputs(request);
39
+ assertHistoricalPromotionEffects(request);
36
40
  const outputDir = path.join(workspace, ".buildchain/release-candidate"),
37
41
  qualified = await candidate({
38
42
  request,
@@ -93,6 +97,13 @@ export async function qualifyPromotion(
93
97
  "sealed-bundle-manifest": qualified.paths.sealedBundleManifest,
94
98
  "recovery-receipt-path": qualified.paths.recoveryReceipt || "",
95
99
  "required-artifacts-path": qualified.paths.publishRequiredArtifacts,
96
- "release-artifact-paths": (qualified.paths.releaseAssets || []).join("\n"),
100
+ "release-artifact-paths": [
101
+ ...(qualified.paths.releaseAssets || []),
102
+ ...historicalReleaseMetadata(
103
+ request["historical-inputs-json"],
104
+ outputDir,
105
+ ),
106
+ ...historicalEvidencePaths(request["historical-inputs-json"], outputDir),
107
+ ].join("\n"),
97
108
  };
98
109
  }
@@ -1,8 +1,8 @@
1
1
  import { resolvePromotionChannel } from "./channel.js";
2
- import { normalizePromotionRequest } from "../promotion-request.js";
2
+ import { normalizeHistoricalPromotion } from "./compatibility.js";
3
3
 
4
4
  export async function routePromotion({ context, request: requestValue, workflowRepository, workflowSha, workflowRef, runtime }) {
5
- const request = normalizePromotionRequest(requestValue);
5
+ const { request, historical } = normalizeHistoricalPromotion(requestValue, workflowRef);
6
6
  const route = resolvePromotionChannel({
7
7
  publicationChannel: request.channel,
8
8
  targetRef: request["target-ref"] || context.ref.replace(/^refs\/heads\//u, ""),
@@ -10,6 +10,7 @@ export async function routePromotion({ context, request: requestValue, workflowR
10
10
  const entryRef = workflowRef.split("@").at(-1).replace(/^refs\/(?:heads|tags)\//u, "");
11
11
  return {
12
12
  "request-json": JSON.stringify(request),
13
+ ...(historical ? { "historical-inputs-json": historical } : {}),
13
14
  repository: runtime.repository,
14
15
  channel: route.channel,
15
16
  "publication-channel": route.publicationChannel,
@@ -1,3 +1,9 @@
1
+ import {
2
+ historicalEvidenceFileKeys,
3
+ historicalPromotionContext,
4
+ historicalPublisherPath,
5
+ HISTORICAL_PUBLISHER,
6
+ } from "./promotion/compatibility-context.js";
1
7
  import fs from "node:fs";
2
8
  import path from "node:path";
3
9
  import { installationRoot } from "../runtime/installation-root.js";
@@ -92,8 +98,11 @@ export function bindPromotionInvocation(requestValue, selection) {
92
98
  throw new Error("Promotion override decision is missing");
93
99
  projected["promotion-override-used"] = selection["override-used"] === "true";
94
100
  projected["buildchain-expected-major"] = "4";
101
+ const historical = selection["historical-inputs-json"] || "";
102
+ if (historical) historicalPromotionContext(historical);
103
+ projected["historical-inputs-json"] = historical;
95
104
  projected["publication-authority-workflow-path"] =
96
- ".github/workflows/.release-promote.yml";
105
+ historicalPublisherPath(historical);
97
106
  projected.schema = "buildchain.promotion-invocation/v1";
98
107
  return normalizePromotionRequest(projected, "invocation");
99
108
  }
@@ -101,7 +110,11 @@ export function bindPromotionInvocation(requestValue, selection) {
101
110
  export function verifyPromotionInvocation(value) {
102
111
  if (typeof value === "string") value = JSON.parse(value);
103
112
  const normalized = normalizePromotionRequest(value, "invocation");
104
- if (Object.keys(value).length !== Object.keys(normalized).length)
113
+ const omittedCompatibility = !Object.hasOwn(value, "historical-inputs-json");
114
+ if (
115
+ Object.keys(value).length + Number(omittedCompatibility) !==
116
+ Object.keys(normalized).length
117
+ )
105
118
  throw new Error("Promotion invocation must include all normalized fields");
106
119
  if (
107
120
  !/^sha256:[0-9a-f]{64}$/.test(normalized["promotion-contract-lock-digest"])
@@ -109,5 +122,21 @@ export function verifyPromotionInvocation(value) {
109
122
  throw new Error(
110
123
  "Promotion invocation requires its admitted contract lock root",
111
124
  );
125
+ const historical = historicalPromotionContext(
126
+ normalized["historical-inputs-json"],
127
+ );
128
+ if (
129
+ Boolean(historical) !==
130
+ (normalized["publication-authority-workflow-path"] === HISTORICAL_PUBLISHER)
131
+ )
132
+ throw new Error(
133
+ "Promotion publisher does not match its admitted interface",
134
+ );
135
+ if (historical)
136
+ for (const key of historicalEvidenceFileKeys)
137
+ if ((historical.inputs[key] || "") !== normalized[key])
138
+ throw new Error(
139
+ `Historical evidence selection does not match invocation: ${key}`,
140
+ );
112
141
  return normalized;
113
142
  }
@@ -1,3 +1,4 @@
1
+ import { releaseMetadata, releaseMetadataMatches } from "./github-release-metadata.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -358,6 +359,9 @@ export function createGitHubReleaseAssetsAdapter({
358
359
  if (!observed.release) {
359
360
  return { outcome: "absent", providerCode: "github-release-absent" };
360
361
  }
362
+ if (!releaseMetadataMatches(releaseMetadata(expected), observed.release)) {
363
+ return { outcome: "absent", providerCode: "github-release-metadata-incomplete" };
364
+ }
361
365
  const byName = new Map(
362
366
  observed.assets.map((asset) => [asset.name, asset]),
363
367
  );
@@ -409,6 +413,7 @@ export function createGitHubReleaseAssetsAdapter({
409
413
  );
410
414
  }
411
415
  const observed = await releaseAndAssets(effect);
416
+ const metadata = releaseMetadata(expected);
412
417
  let release = observed.release;
413
418
  if (!release) {
414
419
  try {
@@ -419,6 +424,7 @@ export function createGitHubReleaseAssetsAdapter({
419
424
  tag_name: observed.coordinate.tag,
420
425
  target_commitish: effect.subject.sourceSha,
421
426
  name: observed.coordinate.tag,
427
+ ...metadata,
422
428
  draft: false,
423
429
  prerelease: effect.subject.channel === "alpha",
424
430
  make_latest: effect.subject.channel === "alpha" ? "false" : "true",
@@ -441,6 +447,18 @@ export function createGitHubReleaseAssetsAdapter({
441
447
  });
442
448
  }
443
449
  }
450
+ if (!releaseMetadataMatches(metadata, release)) {
451
+ try {
452
+ await octokit.rest.repos.updateRelease({
453
+ owner: observed.coordinate.owner,
454
+ repo: observed.coordinate.repo,
455
+ release_id: release.id,
456
+ ...metadata,
457
+ });
458
+ } catch (error) {
459
+ throw classifyProviderError(error, "github-release-metadata-transient");
460
+ }
461
+ }
444
462
  for (const artifact of expected) {
445
463
  if (byName.has(artifact.name)) continue;
446
464
  try {
@@ -7,6 +7,7 @@ import {
7
7
  selectExecutionRuntime,
8
8
  } from "./selection.js";
9
9
  import { selectAttemptRecoveryRuntime } from "./attempt.js";
10
+ import { historicalEntrySelection } from "./compatibility.js";
10
11
 
11
12
  export async function selectExecutionRuntimeAction(
12
13
  core,
@@ -41,8 +42,10 @@ export async function selectExecutionRuntimeAction(
41
42
  });
42
43
  const workflowSha = core.getInput("workflow-sha", { required: true });
43
44
  const workflowRef = core.getInput("workflow-ref", { required: true });
45
+ const historical = historicalEntrySelection(core.getInput("compatibility-inputs"), workflowRef);
44
46
  const lockPath =
45
47
  core.getInput("contract-lock") ||
48
+ historical.lockPath ||
46
49
  transport?.contract?.path ||
47
50
  (/@(?:refs\/tags\/)?v4-alpha$/u.test(workflowRef)
48
51
  ? ".buildchain/alpha-contract-lock.json"
@@ -55,7 +58,7 @@ export async function selectExecutionRuntimeAction(
55
58
  throw new Error(
56
59
  "Contract lock path must be relative to the consumer repository",
57
60
  );
58
- const runtimeRef = core.getInput("runtime-ref");
61
+ const runtimeRef = core.getInput("runtime-ref") || historical.runtimeRef || "";
59
62
  let selection;
60
63
  if (retained) {
61
64
  if (runtimeRef || resumeRunId)