@kungfu-tech/buildchain 4.1.6 → 4.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +7 -5
- package/CONTRIBUTING.md +12 -6
- package/README.md +9 -5
- package/architecture/action-taxonomy.json +2 -0
- package/architecture/agent-change-map.md +6 -1
- package/architecture/build-orchestration.json +7 -1
- package/architecture/ci-lane-change-budget.json +1836 -0
- package/architecture/consumer-upgrade.json +6482 -0
- package/architecture/internal-capabilities.json +5 -2
- package/architecture/maintainability-debt.json +18 -10
- package/architecture/maintainability-policy.json +9 -9
- package/architecture/release-topology.json +10 -1
- package/architecture/universal-workflow-bootstrap.json +88 -18
- package/architecture/universal-workflow-capability-policy.json +2 -2
- package/architecture/workflow-taxonomy.json +2 -1
- package/contracts/dev-delivery-authority-v2.schema.json +29 -3
- package/contracts/fixtures/consumer-upgrade/build-v4.0.0.json +764 -0
- package/contracts/fixtures/consumer-upgrade/consumer-calls.json +447 -0
- package/contracts/fixtures/consumer-upgrade/dev-delivery-identity-v4.0.0.json +27 -0
- package/contracts/fixtures/consumer-upgrade/dev-pr-auto-merge-v4.0.0.json +256 -0
- package/contracts/fixtures/consumer-upgrade/dev-pr-auto-merge-v4.0.9.json +358 -0
- package/contracts/fixtures/consumer-upgrade/release-v4.0.0.json +815 -0
- package/contracts/promotion-invocation-v1.schema.json +5 -0
- package/dist/site/buildchain-contract.json +40 -23
- package/dist/site/buildchain-site.json +20 -20
- package/dist/site/capability-registry.json +5 -5
- package/dist/site/controller-registry.json +17 -5
- package/dist/site/kfd-claims.json +1376 -143
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +4 -4
- package/dist/site/node-api-registry.json +22 -22
- package/dist/site/page-registry.json +14 -14
- package/dist/site/public-surface-audit.json +1733 -101
- package/dist/site/publication-authority-registry.json +651 -32
- package/dist/site/publication-registry.json +1 -1
- package/dist/site/schemas/dev-delivery-authority-v2.schema.json +29 -3
- package/dist/site/site-manifest.json +5 -5
- package/dist/site/workflow-registry.json +2501 -149
- package/docs/MAP.md +5 -4
- package/docs/code-organization.md +11 -8
- package/docs/getting-started.md +31 -2
- package/docs/migration-inventory.md +8 -6
- package/docs/node-api-reference.md +36 -36
- package/docs/workflow-path-migration.md +8 -7
- package/package.json +1 -1
- package/packages/core/build/artifact/contracts.js +10 -2
- package/packages/core/build/artifact/transport.js +1 -1
- package/packages/core/build/artifact/upload.js +2 -2
- package/packages/core/build/plan/action.js +2 -0
- package/packages/core/build/plan/admission.js +9 -3
- package/packages/core/build/plan/compatibility-artifacts.js +47 -0
- package/packages/core/build/plan/compatibility-plan.js +121 -0
- package/packages/core/build/plan/compatibility.js +162 -0
- package/packages/core/build/plan/configuration.js +10 -37
- package/packages/core/build/plan/identity.js +42 -0
- package/packages/core/build/plan/lifecycle.js +1 -0
- package/packages/core/build/plan/resolve.js +2 -0
- package/packages/core/build/summary/aggregation.js +4 -2
- package/packages/core/build/summary/compatibility.js +24 -0
- package/packages/core/build/summary/finalization.js +18 -0
- package/packages/core/build/summary/historical-outputs.js +88 -0
- package/packages/core/contracts/runtime-contract-inspection.js +8 -0
- package/packages/core/dev-delivery/candidate/submission.js +2 -1
- package/packages/core/dev-delivery/commands/dev-delivery-authority-command-adapters.mjs +2 -1
- package/packages/core/dev-delivery/commands/dev-delivery-authority.mjs +6 -5
- package/packages/core/dev-delivery/commands/dev-delivery-warrant-options.mjs +22 -3
- package/packages/core/dev-delivery/dev-delivery-candidate-identity.js +13 -4
- package/packages/core/dev-delivery/dev-delivery-warrant.js +4 -1
- package/packages/core/dev-delivery/warrant/handoff.js +2 -1
- package/packages/core/dev-delivery/warrant/transitions.js +2 -2
- package/packages/core/governance/buildchain-publication-authority.js +5 -9
- package/packages/core/release/github-release-metadata.js +58 -0
- package/packages/core/release/promote-candidate/action.js +4 -2
- package/packages/core/release/promote-candidate/preparation.js +6 -0
- package/packages/core/release/promote-candidate/publication.js +36 -0
- package/packages/core/release/promote-candidate/release-documents.js +1 -1
- package/packages/core/release/promotion/candidate.js +4 -2
- package/packages/core/release/promotion/compatibility-context.js +93 -0
- package/packages/core/release/promotion/compatibility-qualification.js +48 -0
- package/packages/core/release/promotion/compatibility.js +141 -0
- package/packages/core/release/promotion/historical-evidence-action.js +17 -0
- package/packages/core/release/promotion/historical-evidence-files.js +112 -0
- package/packages/core/release/promotion/historical-evidence.js +231 -0
- package/packages/core/release/promotion/qualification.js +12 -1
- package/packages/core/release/promotion/routing.js +3 -2
- package/packages/core/release/promotion-request.js +31 -2
- package/packages/core/release/release-tail-provider-adapters.js +18 -0
- package/packages/core/runtime/entry/actions.js +4 -1
- package/packages/core/runtime/entry/compatibility.js +53 -0
- package/packages/core/workflow/engine/conformance.js +7 -3
- package/packages/core/workflow/pipeline/controller.js +26 -26
- package/scripts/check-dev-delivery-actions.mjs +12 -0
- package/scripts/check-inventory.mjs +1 -1
- package/scripts/check-public-dogfood-contract.mjs +2 -0
- package/scripts/check-release-topology.mjs +5 -1
- package/scripts/check-self-consumer-contract.mjs +5 -1
- package/scripts/generate-bootstrap-admission.mjs +20 -0
- package/scripts/generate-site-bundle.mjs +5 -5
- package/scripts/generate-workflow-taxonomy.mjs +17 -0
- package/scripts/maintainability-metrics.mjs +30 -2
- 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":
|
|
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 {
|
|
2
|
+
import { normalizeHistoricalPromotion } from "./compatibility.js";
|
|
3
3
|
|
|
4
4
|
export async function routePromotion({ context, request: requestValue, workflowRepository, workflowSha, workflowRef, runtime }) {
|
|
5
|
-
const request =
|
|
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
|
-
|
|
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
|
-
|
|
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)
|