@kungfu-tech/buildchain 4.0.2-alpha.0 → 4.0.2-alpha.4
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/architecture/agent-change-map.md +7 -4
- package/architecture/ci-lane-change-budget.json +76 -8
- package/architecture/internal-capabilities.json +2 -0
- package/architecture/maintainability-debt.json +52 -124
- package/architecture/maintainability-policy.json +15 -10
- package/architecture/release-tail-contract-inventory.json +4 -4
- package/architecture/v3-core-mechanism-inventory.json +1 -0
- package/architecture/v3-v4-live-capability-inventory.json +17 -17
- package/architecture/v4-delivery-warrant-shadow-fixtures.json +5 -5
- package/architecture/v4-release-invocation-fixtures.json +139 -0
- package/architecture/v4-release-topology.json +243 -0
- package/architecture/v4-runtime-semantic-closure.json +4 -1
- package/contracts/buildchain-v2-residuals-v1.json +0 -9
- package/contracts/fixtures/v4-tail-reseal-v1/valid.json +1 -1
- package/contracts/v4-release-invocation-v1.schema.json +134 -0
- package/dist/site/buildchain-contract.json +9 -9
- package/dist/site/buildchain-site.json +7 -7
- package/dist/site/kfd-claims.json +24 -5
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +1 -1
- package/dist/site/node-api-registry.json +90 -15
- package/dist/site/page-registry.json +2 -2
- package/dist/site/public-surface-audit.json +34 -9
- package/dist/site/publication-authority-registry.json +2 -3
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +5 -5
- package/dist/site/workflow-registry.json +36 -11
- package/docs/node-api-reference.md +16 -13
- package/package.json +2 -2
- package/packages/core/dev-delivery-candidate-identity.js +45 -17
- package/packages/core/dev-delivery-execution-transfer.js +6 -7
- package/packages/core/dev-delivery-provider-heartbeat.js +22 -6
- package/packages/core/dev-delivery-warrant-legacy-recovery.js +4 -2
- package/packages/core/dev-delivery-warrant-native-compatibility.js +33 -0
- package/packages/core/dev-delivery-warrant-state.js +54 -54
- package/packages/core/dev-delivery-warrant.js +8 -0
- package/packages/core/dev-delivery-writer-protocol-transition.js +72 -0
- package/packages/core/v4-canonical-contracts.js +10 -0
- package/packages/core/v4-floating-consumer-policy.js +4 -1
- package/packages/core/v4-protected-publication-source.js +93 -0
- package/packages/core/v4-publication-qualification.js +1 -0
- package/packages/core/v4-release-invocation.js +425 -0
- package/scripts/audit-publication-control-plane.mjs +0 -1
- package/scripts/check-inventory.mjs +27 -59
- package/scripts/check-maintainability.mjs +13 -5
- package/scripts/check-v3-v4-capability-inventory.mjs +3 -61
- package/scripts/check-v4-floating-consumer-policy-contract.mjs +10 -20
- package/scripts/check-v4-release-topology.mjs +383 -0
- package/scripts/dev-delivery-warrant.mjs +26 -5
- package/scripts/generate-channel-promotion-workflow.mjs +14 -55
- package/scripts/release-candidate-resolver.mjs +17 -17
- package/scripts/resume-from-candidate-run.mjs +15 -16
- package/scripts/v3-v4-capability-catalog.mjs +107 -0
- package/scripts/v4-declarative-promotion-admission.mjs +4 -1
- package/scripts/v4-release-candidate-adapter.mjs +41 -0
- package/scripts/capture-package-release-propagation.mjs +0 -263
- package/scripts/publication-commit-evidence.mjs +0 -444
- package/scripts/publish-github-artifact-attestation-evidence.mjs +0 -201
- package/scripts/stage-github-artifact-attestation-inputs.mjs +0 -65
|
@@ -110,13 +110,13 @@ function digestFileSync(filePath, algorithm, encoding) {
|
|
|
110
110
|
return hash.digest(encoding);
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
export function selectMergedChannelPullRequest({ pullRequests = [], targetRef, repository }) {
|
|
113
|
+
export function selectMergedChannelPullRequest({ pullRequests = [], targetRef, targetSha = "", repository }) {
|
|
114
114
|
const normalizedTarget = normalizeBranch(targetRef);
|
|
115
115
|
const candidates = pullRequests.filter((pr) => {
|
|
116
|
-
const
|
|
117
|
-
const merged = Boolean(pr.merged_at || pr.mergedAt || pr.
|
|
118
|
-
const
|
|
119
|
-
return merged &&
|
|
116
|
+
const baseRepo = pr.base?.repo?.full_name || pr.baseRepository?.nameWithOwner;
|
|
117
|
+
const merged = Boolean(pr.merged_at || pr.mergedAt || pr.merged === true);
|
|
118
|
+
const rooted = !repository || (baseRepo || pr.head?.repo?.full_name) === repository;
|
|
119
|
+
return merged && rooted && (!targetSha || (pr.merge_commit_sha || pr.mergeCommit?.oid) === targetSha) && normalizeBranch(pr.base?.ref || pr.baseRefName || "") === normalizedTarget;
|
|
120
120
|
});
|
|
121
121
|
candidates.sort((left, right) => {
|
|
122
122
|
const leftTime = Date.parse(left.merged_at || left.updated_at || left.closed_at || "");
|
|
@@ -513,11 +513,11 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
513
513
|
});
|
|
514
514
|
const channelPullRequest = selectMergedChannelPullRequest({
|
|
515
515
|
pullRequests: Array.isArray(pulls) ? pulls : [],
|
|
516
|
-
targetRef: normalizedTarget,
|
|
516
|
+
targetRef: normalizedTarget, targetSha: sha,
|
|
517
517
|
repository: repoInfo.fullName,
|
|
518
518
|
});
|
|
519
519
|
if (!channelPullRequest) {
|
|
520
|
-
throw new Error(`no
|
|
520
|
+
throw new Error(`no exact merged channel PR rooted in ${repoInfo.fullName} found for ${sha} into ${normalizedTarget}`);
|
|
521
521
|
}
|
|
522
522
|
let pullRequest = channelPullRequest;
|
|
523
523
|
if (majorGateTarget) {
|
|
@@ -725,16 +725,6 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
725
725
|
const noun = publishArtifactKind === "npm" ? "npm package tarballs" : "platform manifests";
|
|
726
726
|
throw new Error(`expected at least ${minimumPayloadCount} downloaded ${noun}, found ${downloadedRequiredArtifactCount}`);
|
|
727
727
|
}
|
|
728
|
-
const manifests = platformManifestPaths.map((manifestPath) => JSON.parse(fs.readFileSync(manifestPath, "utf8")));
|
|
729
|
-
const generatedRequiredArtifacts = generatePublishRequiredArtifacts({
|
|
730
|
-
manifests,
|
|
731
|
-
version: passport.target?.version || "",
|
|
732
|
-
kind: publishArtifactKind,
|
|
733
|
-
tarballPaths: npmTarballPaths,
|
|
734
|
-
mainPackage: publishPackageMain,
|
|
735
|
-
});
|
|
736
|
-
const requiredArtifactsPath = path.join(resolvedOutput, "publish-required-artifacts.json");
|
|
737
|
-
fs.writeFileSync(requiredArtifactsPath, `${JSON.stringify(generatedRequiredArtifacts, null, 2)}\n`);
|
|
738
728
|
const sealedBundle = publishArtifactKind === "npm"
|
|
739
729
|
? createResolvedPublicationSealedBundle({
|
|
740
730
|
bundleRoot: payloadDir,
|
|
@@ -750,6 +740,16 @@ export async function resolveReleaseCandidateArtifacts({
|
|
|
750
740
|
releaseAssetPaths,
|
|
751
741
|
})
|
|
752
742
|
: undefined;
|
|
743
|
+
const manifests = platformManifestPaths.map((manifestPath) => JSON.parse(fs.readFileSync(manifestPath, "utf8")));
|
|
744
|
+
const generatedRequiredArtifacts = generatePublishRequiredArtifacts({
|
|
745
|
+
manifests,
|
|
746
|
+
version: [sealedBundle?.manifest?.npm?.version, passport.target?.version, ""].find(Boolean),
|
|
747
|
+
kind: publishArtifactKind,
|
|
748
|
+
tarballPaths: npmTarballPaths,
|
|
749
|
+
mainPackage: publishPackageMain,
|
|
750
|
+
});
|
|
751
|
+
const requiredArtifactsPath = path.join(resolvedOutput, "publish-required-artifacts.json");
|
|
752
|
+
fs.writeFileSync(requiredArtifactsPath, `${JSON.stringify(generatedRequiredArtifacts, null, 2)}\n`);
|
|
753
753
|
const sealedBundleManifestPath = sealedBundle
|
|
754
754
|
? path.join(resolvedOutput, "sealed-bundle.json")
|
|
755
755
|
: "";
|
|
@@ -785,14 +785,13 @@ async function recoverCandidateEvidence({
|
|
|
785
785
|
for (const artifact of [selected.passport, selected.summary]) initialDownloads.push(await downloadArtifact({ artifact, repoInfo, apiUrl, token, archiveDir, bundleRoot, fetchImpl }));
|
|
786
786
|
const passport = readOnlyJson(initialDownloads[0].files.filter((file) => path.basename(file.path) === "release-candidate-passport.json"), "release-candidate-passport.json");
|
|
787
787
|
const buildSummary = readOnlyJson(initialDownloads[1].files.filter((file) => path.basename(file.path) === "build-summary.json"), "build-summary.json");
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
);
|
|
792
|
-
const stageCapsuleSidecar =
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
: undefined;
|
|
788
|
+
const [stageCapsuleFile, publicationQualificationFile] = [
|
|
789
|
+
"release-candidate-stage-capsules.json",
|
|
790
|
+
"release-candidate-publication-qualification.json",
|
|
791
|
+
].map((name) => initialDownloads[0].files.find((file) => path.basename(file.path) === name));
|
|
792
|
+
const stageCapsuleSidecar = stageCapsuleFile
|
|
793
|
+
? readOnlyJson([stageCapsuleFile], "release-candidate-stage-capsules.json")
|
|
794
|
+
: undefined;
|
|
796
795
|
const { names: requiredNames, publicationNames } = candidateArtifactNames({ passport, selected, artifacts, artifactPatterns });
|
|
797
796
|
const chosen = artifacts.filter((artifact) => requiredNames.has(artifact.name));
|
|
798
797
|
if (chosen.length !== requiredNames.size) {
|
|
@@ -804,7 +803,7 @@ async function recoverCandidateEvidence({
|
|
|
804
803
|
for (const artifact of chosen.filter((entry) => ![selected.passport.id, selected.summary.id].includes(entry.id))) downloads.push(await downloadArtifact({ artifact, repoInfo, apiUrl, token, archiveDir, bundleRoot, fetchImpl }));
|
|
805
804
|
return {
|
|
806
805
|
run, workflow, selected, resolvedOutput, bundleRoot, initialDownloads,
|
|
807
|
-
passport, buildSummary, stageCapsuleSidecar, chosen, downloads, publicationNames,
|
|
806
|
+
passport, buildSummary, stageCapsuleSidecar, stageCapsuleFile, publicationQualificationFile, chosen, downloads, publicationNames,
|
|
808
807
|
};
|
|
809
808
|
}
|
|
810
809
|
|
|
@@ -878,7 +877,7 @@ export async function resumeFromCandidateRun({
|
|
|
878
877
|
try {
|
|
879
878
|
const {
|
|
880
879
|
run, workflow, selected, resolvedOutput, bundleRoot, initialDownloads,
|
|
881
|
-
passport, buildSummary, stageCapsuleSidecar, chosen, downloads, publicationNames,
|
|
880
|
+
passport, buildSummary, stageCapsuleSidecar, stageCapsuleFile, publicationQualificationFile, chosen, downloads, publicationNames,
|
|
882
881
|
} = await recoverCandidateEvidence({
|
|
883
882
|
repoInfo, runId, artifactName, artifactPatterns, requiredArtifactCount,
|
|
884
883
|
outputDir, apiUrl, token, fetchImpl, archiveDir,
|
|
@@ -1023,9 +1022,9 @@ export async function resumeFromCandidateRun({
|
|
|
1023
1022
|
sealedBundleRoot: publication.manifest ? outputPath(bundleRoot) : "",
|
|
1024
1023
|
sealedBundleManifest: publication.manifest ? outputPath(sealedManifestPath) : "",
|
|
1025
1024
|
recoveryReceipt: outputPath(recoveryReceiptPath),
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1025
|
+
stageCapsules: stageCapsuleFile ? outputPath(stageCapsuleFile.absolutePath) : "",
|
|
1026
|
+
publicationQualification: publicationQualificationFile ? outputPath(publicationQualificationFile.absolutePath) : "",
|
|
1027
|
+
runtimeResumeEvidence: runtimeResumeEvidencePath ? outputPath(runtimeResumeEvidencePath) : "",
|
|
1029
1028
|
},
|
|
1030
1029
|
};
|
|
1031
1030
|
} finally {
|
|
@@ -1077,10 +1076,10 @@ export async function resumeFromCandidateRunCli() {
|
|
|
1077
1076
|
"release-candidate-run-url": result.run.url,
|
|
1078
1077
|
"release-candidate-recovery-receipt-path": result.paths.recoveryReceipt,
|
|
1079
1078
|
"release-candidate-recovery-root": result.receipt.root,
|
|
1079
|
+
"release-candidate-stage-capsules-path": result.paths.stageCapsules,
|
|
1080
|
+
"release-candidate-publication-qualification-path": result.paths.publicationQualification,
|
|
1080
1081
|
"v4-runtime-resume-evidence-path": result.paths.runtimeResumeEvidence,
|
|
1081
|
-
"v4-runtime-resume-finalize-command": result.paths.runtimeResumeEvidence
|
|
1082
|
-
? "node .buildchain/runtime/promotion-shell/scripts/resume-from-candidate-run.mjs finalize"
|
|
1083
|
-
: "",
|
|
1082
|
+
"v4-runtime-resume-finalize-command": result.paths.runtimeResumeEvidence ? "node .buildchain/runtime/promotion-shell/scripts/resume-from-candidate-run.mjs finalize" : "",
|
|
1084
1083
|
"release-candidate-root": result.candidateRoot,
|
|
1085
1084
|
"release-candidate-artifact-root": result.artifactRoot,
|
|
1086
1085
|
"publish-sealed-bundle-root": result.paths.sealedBundleRoot,
|
|
@@ -23,6 +23,9 @@ const PLATFORM_MARKERS = Object.freeze({
|
|
|
23
23
|
"self-hosted": /\bself-hosted\b/iu,
|
|
24
24
|
windows: /\bwindows\b/iu,
|
|
25
25
|
});
|
|
26
|
+
const LIVE_V4_PROTECTED_BRANCH = "refs/heads/dev/v4/v4.0";
|
|
27
|
+
const LIVE_V4_PROTECTED_LINEAGE_REF = "refs/remotes/origin/dev/v4/v4.0";
|
|
28
|
+
const PROTECTED_LINEAGE_DEPTH = 256;
|
|
26
29
|
|
|
27
30
|
export function sha256(value) {
|
|
28
31
|
return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
|
|
@@ -47,6 +50,110 @@ export function git(root, args, { trim = true } = {}) {
|
|
|
47
50
|
return trim ? output.trim() : output;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
export function assertCapabilityCutAncestor({
|
|
54
|
+
root = process.cwd(),
|
|
55
|
+
revision,
|
|
56
|
+
descendant = "HEAD",
|
|
57
|
+
label = "capability cut",
|
|
58
|
+
} = {}) {
|
|
59
|
+
try {
|
|
60
|
+
execFileSync("git", ["merge-base", "--is-ancestor", revision, descendant], {
|
|
61
|
+
cwd: root,
|
|
62
|
+
stdio: "ignore",
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${label} ${revision} must be an ancestor of ${descendant}; regenerate the cut after rebasing instead of relying on a retained local object`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ensureCapabilityCutAncestor({
|
|
72
|
+
root = process.cwd(),
|
|
73
|
+
revision,
|
|
74
|
+
descendant = "HEAD",
|
|
75
|
+
label = "capability cut",
|
|
76
|
+
} = {}) {
|
|
77
|
+
try {
|
|
78
|
+
assertCapabilityCutAncestor({ root, revision, descendant, label });
|
|
79
|
+
return;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (git(root, ["rev-parse", "--is-shallow-repository"]) !== "true")
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
const descendantCommit = git(root, ["rev-parse", `${descendant}^{commit}`]);
|
|
85
|
+
try {
|
|
86
|
+
execFileSync(
|
|
87
|
+
"git",
|
|
88
|
+
["fetch", "--no-tags", "--depth=128", "origin", descendantCommit],
|
|
89
|
+
{ cwd: root, stdio: "ignore" },
|
|
90
|
+
);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`${label} ${revision} ancestry could not be hydrated from ${descendantCommit} through a bounded origin fetch`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
assertCapabilityCutAncestor({ root, revision, descendant, label });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function protectedV4TreeWitness(root, descendant) {
|
|
100
|
+
try {
|
|
101
|
+
execFileSync(
|
|
102
|
+
"git",
|
|
103
|
+
[
|
|
104
|
+
"fetch",
|
|
105
|
+
"--no-tags",
|
|
106
|
+
`--depth=${PROTECTED_LINEAGE_DEPTH}`,
|
|
107
|
+
"origin",
|
|
108
|
+
`${LIVE_V4_PROTECTED_BRANCH}:${LIVE_V4_PROTECTED_LINEAGE_REF}`,
|
|
109
|
+
],
|
|
110
|
+
{ cwd: root, stdio: "ignore" },
|
|
111
|
+
);
|
|
112
|
+
} catch {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`protected v4 lineage ${LIVE_V4_PROTECTED_BRANCH} could not be hydrated through a bounded origin fetch`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const tree = git(root, ["rev-parse", `${descendant}^{tree}`]);
|
|
118
|
+
return (
|
|
119
|
+
git(root, [
|
|
120
|
+
"log",
|
|
121
|
+
`--max-count=${PROTECTED_LINEAGE_DEPTH}`,
|
|
122
|
+
"--format=%H %T",
|
|
123
|
+
LIVE_V4_PROTECTED_LINEAGE_REF,
|
|
124
|
+
])
|
|
125
|
+
.split("\n")
|
|
126
|
+
.map((row) => row.split(" "))
|
|
127
|
+
.find(([, candidateTree]) => candidateTree === tree)?.[0] || ""
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function ensureCapabilityCutLineage({
|
|
132
|
+
root = process.cwd(),
|
|
133
|
+
revision,
|
|
134
|
+
descendant = "HEAD",
|
|
135
|
+
label = "capability cut",
|
|
136
|
+
} = {}) {
|
|
137
|
+
try {
|
|
138
|
+
ensureCapabilityCutAncestor({ root, revision, descendant, label });
|
|
139
|
+
return { mode: "direct-ancestry", witness: descendant };
|
|
140
|
+
} catch (directError) {
|
|
141
|
+
const witness = protectedV4TreeWitness(root, descendant);
|
|
142
|
+
if (!witness)
|
|
143
|
+
throw new Error(
|
|
144
|
+
`${label} ${revision} is not an ancestor of ${descendant}, and ${descendant} has no tree-equivalent commit in the bounded protected v4 lineage`,
|
|
145
|
+
{ cause: directError },
|
|
146
|
+
);
|
|
147
|
+
ensureCapabilityCutAncestor({
|
|
148
|
+
root,
|
|
149
|
+
revision,
|
|
150
|
+
descendant: witness,
|
|
151
|
+
label: `${label} protected-lineage witness`,
|
|
152
|
+
});
|
|
153
|
+
return { mode: "protected-tree-equivalent", witness };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
50
157
|
function gitJson(root, revision, relPath) {
|
|
51
158
|
return JSON.parse(
|
|
52
159
|
git(root, ["show", `${revision}:${relPath}`], { trim: false }),
|
|
@@ -8,7 +8,10 @@ export function admitV4DeclarativePromotion({
|
|
|
8
8
|
runtimeRef,
|
|
9
9
|
declarative,
|
|
10
10
|
}) {
|
|
11
|
-
if (
|
|
11
|
+
if (
|
|
12
|
+
declarative !== true &&
|
|
13
|
+
!/^v4(?:$|[-./])/u.test(String(runtimeRef || ""))
|
|
14
|
+
) {
|
|
12
15
|
return Object.freeze({ mode: "legacy", admitted: true });
|
|
13
16
|
}
|
|
14
17
|
if (declarative !== true) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
|
|
9
|
+
export function resolveV4ReleaseCandidateAdapter({
|
|
10
|
+
resumeCandidateRunId = "",
|
|
11
|
+
} = {}) {
|
|
12
|
+
const mode = String(resumeCandidateRunId || "").trim() ? "recovery" : "fresh";
|
|
13
|
+
return Object.freeze({
|
|
14
|
+
mode,
|
|
15
|
+
script:
|
|
16
|
+
mode === "recovery"
|
|
17
|
+
? "scripts/resume-from-candidate-run.mjs"
|
|
18
|
+
: "scripts/release-candidate-resolver.mjs",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function runV4ReleaseCandidateAdapter({
|
|
23
|
+
env = process.env,
|
|
24
|
+
exec = execFileSync,
|
|
25
|
+
} = {}) {
|
|
26
|
+
const route = resolveV4ReleaseCandidateAdapter({
|
|
27
|
+
resumeCandidateRunId: env.BUILDCHAIN_RESUME_CANDIDATE_RUN_ID,
|
|
28
|
+
});
|
|
29
|
+
exec(process.execPath, [path.join(root, route.script)], {
|
|
30
|
+
env,
|
|
31
|
+
stdio: "inherit",
|
|
32
|
+
});
|
|
33
|
+
return route;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (
|
|
37
|
+
process.argv[1] &&
|
|
38
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
39
|
+
) {
|
|
40
|
+
runV4ReleaseCandidateAdapter();
|
|
41
|
+
}
|
|
@@ -1,263 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import crypto from "node:crypto";
|
|
4
|
-
import fs from "node:fs";
|
|
5
|
-
import os from "node:os";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { execFileSync } from "node:child_process";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
import {
|
|
10
|
-
createPackageReleasePropagationCapture,
|
|
11
|
-
normalizePackageReleasePropagationConfig,
|
|
12
|
-
} from "../packages/core/release-propagation-capture.js";
|
|
13
|
-
import { stableJson } from "../packages/core/release-propagation-common.js";
|
|
14
|
-
|
|
15
|
-
function parseArgs(argv) {
|
|
16
|
-
const args = {};
|
|
17
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
18
|
-
const token = argv[index];
|
|
19
|
-
if (!token.startsWith("--")) throw new Error(`unexpected argument: ${token}`);
|
|
20
|
-
const key = token.slice(2);
|
|
21
|
-
const value = argv[index + 1];
|
|
22
|
-
if (!value || value.startsWith("--")) throw new Error(`${token} requires a value`);
|
|
23
|
-
args[key] = value;
|
|
24
|
-
index += 1;
|
|
25
|
-
}
|
|
26
|
-
return args;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function required(args, key) {
|
|
30
|
-
const value = String(args[key] || "").trim();
|
|
31
|
-
if (!value) throw new Error(`--${key} is required`);
|
|
32
|
-
return value;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function sha256(bytes) {
|
|
36
|
-
return crypto.createHash("sha256").update(bytes).digest("hex");
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function commandJson(command, args, options = {}) {
|
|
40
|
-
const text = execFileSync(command, args, { encoding: "utf8", ...options });
|
|
41
|
-
return JSON.parse(text);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function assertSourcePath(value) {
|
|
45
|
-
const sourcePath = String(value || "").trim();
|
|
46
|
-
if (!sourcePath || path.isAbsolute(sourcePath) || sourcePath.split("/").includes("..") || /[\r\n]/.test(sourcePath)) {
|
|
47
|
-
throw new Error("release propagation config path must be a safe repository-relative path");
|
|
48
|
-
}
|
|
49
|
-
return sourcePath;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function hasCommit(sourceSha, cwd) {
|
|
53
|
-
try {
|
|
54
|
-
execFileSync("git", ["cat-file", "-e", `${sourceSha}^{commit}`], {
|
|
55
|
-
cwd,
|
|
56
|
-
stdio: "ignore",
|
|
57
|
-
});
|
|
58
|
-
return true;
|
|
59
|
-
} catch {
|
|
60
|
-
return false;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function readConfigAtSource(sourceSha, configPath, cwd) {
|
|
65
|
-
if (!hasCommit(sourceSha, cwd)) {
|
|
66
|
-
try {
|
|
67
|
-
execFileSync("git", ["fetch", "--no-tags", "--depth=1", "origin", sourceSha], {
|
|
68
|
-
cwd,
|
|
69
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
70
|
-
});
|
|
71
|
-
} catch (error) {
|
|
72
|
-
const detail = String(error.stderr || error.message || "unknown git fetch failure").trim();
|
|
73
|
-
throw new Error(`exact release source ${sourceSha} is unavailable from origin: ${detail}`);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const bytes = execFileSync("git", ["show", `${sourceSha}:${configPath}`], {
|
|
77
|
-
cwd,
|
|
78
|
-
encoding: "utf8",
|
|
79
|
-
});
|
|
80
|
-
const parsed = JSON.parse(bytes);
|
|
81
|
-
return { bytes, parsed, normalized: normalizePackageReleasePropagationConfig(parsed) };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function resolveTagTarget(repository, tag) {
|
|
85
|
-
let object = commandJson("gh", ["api", `repos/${repository}/git/ref/tags/${tag}`]).object;
|
|
86
|
-
for (let depth = 0; depth < 8 && object?.type === "tag"; depth += 1) {
|
|
87
|
-
object = commandJson("gh", ["api", `repos/${repository}/git/tags/${object.sha}`]).object;
|
|
88
|
-
}
|
|
89
|
-
if (object?.type !== "commit" || !/^[0-9a-f]{40}$/i.test(object.sha || "")) {
|
|
90
|
-
throw new Error(`release tag ${tag} does not resolve to one exact commit`);
|
|
91
|
-
}
|
|
92
|
-
return object.sha.toLowerCase();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function resolvePackageFact(packageName, version) {
|
|
96
|
-
const fact = commandJson("npm", [
|
|
97
|
-
"view",
|
|
98
|
-
`${packageName}@${version}`,
|
|
99
|
-
"version",
|
|
100
|
-
"dist.integrity",
|
|
101
|
-
"gitHead",
|
|
102
|
-
"--json",
|
|
103
|
-
"--registry=https://registry.npmjs.org/",
|
|
104
|
-
]);
|
|
105
|
-
return {
|
|
106
|
-
name: packageName,
|
|
107
|
-
version: String(fact.version || ""),
|
|
108
|
-
integrity: String(fact.dist?.integrity || fact["dist.integrity"] || ""),
|
|
109
|
-
gitHead: String(fact.gitHead || "").toLowerCase(),
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function verifyPublicReleasePassport({ repository, tag, localPath }) {
|
|
114
|
-
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "buildchain-release-passport-"));
|
|
115
|
-
try {
|
|
116
|
-
execFileSync("gh", [
|
|
117
|
-
"release", "download", tag,
|
|
118
|
-
"--repo", repository,
|
|
119
|
-
"--pattern", "buildchain.release.json",
|
|
120
|
-
"--dir", temporary,
|
|
121
|
-
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
122
|
-
const localBytes = fs.readFileSync(localPath);
|
|
123
|
-
const remotePath = path.join(temporary, "buildchain.release.json");
|
|
124
|
-
const remoteBytes = fs.readFileSync(remotePath);
|
|
125
|
-
const localDigest = sha256(localBytes);
|
|
126
|
-
const remoteDigest = sha256(remoteBytes);
|
|
127
|
-
if (localDigest !== remoteDigest) {
|
|
128
|
-
throw new Error("public release passport bytes disagree with finalized local passport");
|
|
129
|
-
}
|
|
130
|
-
return {
|
|
131
|
-
url: `https://github.com/${repository}/releases/download/${tag}/buildchain.release.json`,
|
|
132
|
-
sha256: remoteDigest,
|
|
133
|
-
};
|
|
134
|
-
} finally {
|
|
135
|
-
fs.rmSync(temporary, { recursive: true, force: true });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function resolveBaseShas(config) {
|
|
140
|
-
return Object.fromEntries(config.targets.map((targetId) => {
|
|
141
|
-
const node = config.graph.nodes.find((entry) => entry.id === targetId);
|
|
142
|
-
if (!node?.baseRef) throw new Error(`configured propagation target ${targetId} has no baseRef`);
|
|
143
|
-
const response = commandJson("gh", ["api", `repos/${node.repository}/commits/${node.baseRef}`]);
|
|
144
|
-
const sha = String(response.sha || "").toLowerCase();
|
|
145
|
-
if (!/^[0-9a-f]{40}$/.test(sha)) {
|
|
146
|
-
throw new Error(`configured propagation target ${targetId} baseRef did not resolve exactly`);
|
|
147
|
-
}
|
|
148
|
-
return [targetId, sha];
|
|
149
|
-
}));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function writeOutput(name, value) {
|
|
153
|
-
if (!process.env.GITHUB_OUTPUT) return;
|
|
154
|
-
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
export function capturePackageReleasePropagation({
|
|
158
|
-
config,
|
|
159
|
-
upstreamRelease,
|
|
160
|
-
expectedBaseShas,
|
|
161
|
-
outputDir,
|
|
162
|
-
configBytes = "",
|
|
163
|
-
} = {}) {
|
|
164
|
-
const captured = createPackageReleasePropagationCapture({
|
|
165
|
-
config,
|
|
166
|
-
upstreamRelease,
|
|
167
|
-
expectedBaseShas,
|
|
168
|
-
});
|
|
169
|
-
fs.mkdirSync(outputDir, { recursive: true });
|
|
170
|
-
fs.writeFileSync(
|
|
171
|
-
path.join(outputDir, "config.json"),
|
|
172
|
-
configBytes || stableJson(captured.config),
|
|
173
|
-
);
|
|
174
|
-
fs.writeFileSync(path.join(outputDir, "upstream-release.json"), stableJson(captured.upstreamRelease));
|
|
175
|
-
fs.writeFileSync(path.join(outputDir, "plan.json"), stableJson(captured.plan));
|
|
176
|
-
for (const item of captured.works) {
|
|
177
|
-
const targetDir = path.join(outputDir, "work", item.propagationKey);
|
|
178
|
-
fs.mkdirSync(targetDir, { recursive: true });
|
|
179
|
-
fs.writeFileSync(path.join(targetDir, "work.json"), stableJson(item.work));
|
|
180
|
-
fs.writeFileSync(path.join(targetDir, "status.json"), stableJson(item.status));
|
|
181
|
-
}
|
|
182
|
-
return captured;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function main(argv = process.argv.slice(2)) {
|
|
186
|
-
const args = parseArgs(argv);
|
|
187
|
-
const repository = required(args, "repository");
|
|
188
|
-
const channel = required(args, "channel");
|
|
189
|
-
const sourceSha = required(args, "source-sha").toLowerCase();
|
|
190
|
-
const tag = required(args, "tag");
|
|
191
|
-
const configPath = assertSourcePath(required(args, "config-path"));
|
|
192
|
-
const releasePassportPath = path.resolve(required(args, "release-passport-path"));
|
|
193
|
-
const outputDir = path.resolve(required(args, "output-dir"));
|
|
194
|
-
if (!/^[0-9a-f]{40}$/.test(sourceSha)) throw new Error("--source-sha must be an exact commit SHA");
|
|
195
|
-
if (tag !== `v${tag.replace(/^v/, "")}`) throw new Error("--tag must be an exact v-prefixed release tag");
|
|
196
|
-
const version = tag.slice(1);
|
|
197
|
-
const configSource = readConfigAtSource(sourceSha, configPath, process.cwd());
|
|
198
|
-
const sourceNode = configSource.normalized.graph.nodes.find(
|
|
199
|
-
(node) => node.id === configSource.normalized.sourceNode,
|
|
200
|
-
);
|
|
201
|
-
const packageFact = resolvePackageFact(sourceNode.package, version);
|
|
202
|
-
const tagTargetSha = resolveTagTarget(repository, tag);
|
|
203
|
-
const releasePassport = verifyPublicReleasePassport({
|
|
204
|
-
repository,
|
|
205
|
-
tag,
|
|
206
|
-
localPath: releasePassportPath,
|
|
207
|
-
});
|
|
208
|
-
const upstreamRelease = {
|
|
209
|
-
repository,
|
|
210
|
-
channel,
|
|
211
|
-
tag,
|
|
212
|
-
sourceSha,
|
|
213
|
-
tagTargetSha,
|
|
214
|
-
package: packageFact,
|
|
215
|
-
releasePassport,
|
|
216
|
-
};
|
|
217
|
-
const expectedBaseShas = resolveBaseShas(configSource.normalized);
|
|
218
|
-
const captured = capturePackageReleasePropagation({
|
|
219
|
-
config: configSource.parsed,
|
|
220
|
-
configBytes: configSource.bytes,
|
|
221
|
-
upstreamRelease,
|
|
222
|
-
expectedBaseShas,
|
|
223
|
-
outputDir,
|
|
224
|
-
});
|
|
225
|
-
const artifactName = `package-propagation-work-${version}-${sourceSha}`;
|
|
226
|
-
const workRoots = captured.works.map((item) => item.work.contentRoot);
|
|
227
|
-
writeOutput("configured", "true");
|
|
228
|
-
writeOutput("artifact-name", artifactName);
|
|
229
|
-
writeOutput("work-roots-json", JSON.stringify(workRoots));
|
|
230
|
-
process.stdout.write(stableJson({
|
|
231
|
-
schemaVersion: 1,
|
|
232
|
-
contract: "kungfu-buildchain-package-release-propagation-capture-result",
|
|
233
|
-
artifactName,
|
|
234
|
-
release: {
|
|
235
|
-
repository,
|
|
236
|
-
channel,
|
|
237
|
-
tag,
|
|
238
|
-
sourceSha,
|
|
239
|
-
tagTargetSha,
|
|
240
|
-
package: packageFact,
|
|
241
|
-
releasePassport,
|
|
242
|
-
},
|
|
243
|
-
workCount: captured.works.length,
|
|
244
|
-
works: captured.works.map((item) => ({
|
|
245
|
-
target: item.target,
|
|
246
|
-
repository: item.repository,
|
|
247
|
-
propagationKey: item.propagationKey,
|
|
248
|
-
workId: item.work.workId,
|
|
249
|
-
workRoot: item.work.contentRoot,
|
|
250
|
-
nextAction: item.status.nextAction,
|
|
251
|
-
})),
|
|
252
|
-
}));
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
256
|
-
if (isMain) {
|
|
257
|
-
try {
|
|
258
|
-
main();
|
|
259
|
-
} catch (error) {
|
|
260
|
-
console.error(error.message);
|
|
261
|
-
process.exitCode = 1;
|
|
262
|
-
}
|
|
263
|
-
}
|