@kungfu-tech/buildchain 4.0.2-alpha.44 → 4.0.2-alpha.46
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 +1 -1
- package/architecture/maintainability-debt.json +24 -25
- package/architecture/maintainability-policy.json +4 -4
- package/architecture/v4-release-topology.json +9 -6
- package/architecture/v4-universal-workflow-train-admission.json +1 -1
- package/dist/site/buildchain-contract.json +7 -7
- package/dist/site/buildchain-site.json +55 -14
- package/dist/site/capability-registry.json +2 -2
- package/dist/site/kfd-claims.json +29 -5
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +3 -3
- package/dist/site/node-api-registry.json +154 -13
- package/dist/site/page-registry.json +48 -7
- package/dist/site/public-surface-audit.json +7 -3
- package/dist/site/publication-authority-registry.json +5 -17
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +2 -1
- package/dist/site/site-manifest.json +7 -7
- package/dist/site/workflow-registry.json +5 -5
- package/docs/MAP.md +1 -0
- package/docs/node-api-reference.md +26 -15
- package/docs/oci-publication.md +82 -0
- package/docs/publish-transaction.md +36 -3
- package/package.json +3 -2
- package/packages/core/buildchain-v4-domain.wasm +0 -0
- package/packages/core/next-development-transition.js +5 -6
- package/packages/core/oci-publication-bundle.js +241 -0
- package/packages/core/publication-sealed-bundle.js +77 -13
- package/packages/core/release-tail-product-capabilities.js +9 -0
- package/packages/core/v4-canonical-contracts.js +1 -0
- package/packages/core/v4-domain-wasm-artifact.js +2 -2
- package/packages/core/v4-product-publication.js +2 -0
- package/scripts/check-v4-release-topology.mjs +14 -4
- package/scripts/generate-channel-promotion-workflow.mjs +2 -19
- package/scripts/generate-v4-universal-workflow-facades.mjs +8 -2
- package/scripts/publication-candidate-kind.mjs +92 -0
- package/scripts/publication-candidate-sealer.mjs +16 -1
- package/scripts/release-candidate-resolver.mjs +6 -17
- package/scripts/resume-from-candidate-run.mjs +10 -17
- package/scripts/site-capability-metadata.mjs +1 -0
- package/scripts/v4-product-publication-intent.mjs +9 -1
- package/scripts/v4-universal-workflow-engine.mjs +2 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { v4ContentRoot } from "./v4-canonical-contracts.js";
|
|
5
|
+
|
|
6
|
+
export const OCI_FAMILY_SCHEMA = "kungfu-buildchain-oci-family/v1";
|
|
7
|
+
const digestPattern = /^sha256:[0-9a-f]{64}$/u;
|
|
8
|
+
const namePattern = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/u;
|
|
9
|
+
|
|
10
|
+
export function sealOciPublicationBundle({ bundleRoot, body }) {
|
|
11
|
+
const manifest = {
|
|
12
|
+
...body,
|
|
13
|
+
root: v4ContentRoot("oci-publication-family", body),
|
|
14
|
+
};
|
|
15
|
+
verifyOciPublicationBundle({
|
|
16
|
+
bundleRoot,
|
|
17
|
+
manifest,
|
|
18
|
+
repository: body.repository,
|
|
19
|
+
sourceSha: body.sourceSha,
|
|
20
|
+
version: body.version,
|
|
21
|
+
});
|
|
22
|
+
return manifest;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requireValue(condition, message) {
|
|
26
|
+
if (!condition) throw new Error(`OCI bundle: ${message}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ociBundleFile(root, relative) {
|
|
30
|
+
requireValue(
|
|
31
|
+
typeof relative === "string" &&
|
|
32
|
+
relative.length > 0 &&
|
|
33
|
+
!relative.includes("\\") &&
|
|
34
|
+
!path.isAbsolute(relative) &&
|
|
35
|
+
!relative.split(/[\\/]/u).some((p) => p === ".." || p === "." || !p),
|
|
36
|
+
"unsafe file path",
|
|
37
|
+
);
|
|
38
|
+
const base = fs.realpathSync(root);
|
|
39
|
+
let current = base;
|
|
40
|
+
for (const segment of relative.split("/")) {
|
|
41
|
+
current = path.join(current, segment);
|
|
42
|
+
requireValue(
|
|
43
|
+
!fs.lstatSync(current).isSymbolicLink(),
|
|
44
|
+
"symlinks are forbidden",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
requireValue(
|
|
48
|
+
fs.statSync(current).isFile() &&
|
|
49
|
+
fs.realpathSync(current).startsWith(`${base}${path.sep}`),
|
|
50
|
+
"file escapes bundle",
|
|
51
|
+
);
|
|
52
|
+
return current;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fileDigest(file) {
|
|
56
|
+
const hash = crypto.createHash("sha256"),
|
|
57
|
+
buffer = Buffer.alloc(1024 * 1024);
|
|
58
|
+
const fd = fs.openSync(file, "r");
|
|
59
|
+
try {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const size = fs.readSync(fd, buffer);
|
|
62
|
+
if (!size) break;
|
|
63
|
+
hash.update(buffer.subarray(0, size));
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
fs.closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
return `sha256:${hash.digest("hex")}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function verifyImageProvenance(image, config, sourceSha, version) {
|
|
72
|
+
const labels = config.config?.Labels || {};
|
|
73
|
+
requireValue(
|
|
74
|
+
image.content &&
|
|
75
|
+
/^[0-9a-f]{40}$/u.test(image.content.sourceSha) &&
|
|
76
|
+
typeof image.content.version === "string" &&
|
|
77
|
+
image.content.version.length > 0,
|
|
78
|
+
"missing image content provenance",
|
|
79
|
+
);
|
|
80
|
+
requireValue(
|
|
81
|
+
labels["org.opencontainers.image.revision"] === image.content.sourceSha &&
|
|
82
|
+
labels["org.opencontainers.image.version"] === image.content.version,
|
|
83
|
+
"image content provenance mismatch",
|
|
84
|
+
);
|
|
85
|
+
if (image.action === "built")
|
|
86
|
+
requireValue(
|
|
87
|
+
image.content.sourceSha === sourceSha &&
|
|
88
|
+
image.content.version === version,
|
|
89
|
+
"built image is not from the candidate source",
|
|
90
|
+
);
|
|
91
|
+
if (image.contractMajor !== undefined)
|
|
92
|
+
requireValue(
|
|
93
|
+
Number.isInteger(image.contractMajor) &&
|
|
94
|
+
image.contractMajor > 0 &&
|
|
95
|
+
labels["io.kungfu.image.contract-major"] ===
|
|
96
|
+
String(image.contractMajor),
|
|
97
|
+
"image contract major mismatch",
|
|
98
|
+
);
|
|
99
|
+
if (image.parentDigest)
|
|
100
|
+
requireValue(
|
|
101
|
+
digestPattern.test(image.parentDigest) &&
|
|
102
|
+
labels["io.kungfu.image.parent-digest"] === image.parentDigest,
|
|
103
|
+
"image parent digest mismatch",
|
|
104
|
+
);
|
|
105
|
+
if (image.content.materialSha)
|
|
106
|
+
requireValue(
|
|
107
|
+
/^[0-9a-f]{40}$/u.test(image.content.materialSha) &&
|
|
108
|
+
labels["io.kungfu.buildchain.release-material-sha"] ===
|
|
109
|
+
image.content.materialSha,
|
|
110
|
+
"image material provenance mismatch",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function verifyOciPublicationBundle({
|
|
115
|
+
bundleRoot,
|
|
116
|
+
manifest,
|
|
117
|
+
repository,
|
|
118
|
+
sourceSha,
|
|
119
|
+
version,
|
|
120
|
+
}) {
|
|
121
|
+
requireValue(
|
|
122
|
+
manifest?.schema === OCI_FAMILY_SCHEMA,
|
|
123
|
+
"unsupported manifest schema",
|
|
124
|
+
);
|
|
125
|
+
const { root, ...body } = manifest;
|
|
126
|
+
const computed = v4ContentRoot("oci-publication-family", body);
|
|
127
|
+
requireValue(root === computed, "manifest root mismatch");
|
|
128
|
+
requireValue(
|
|
129
|
+
manifest.repository === repository &&
|
|
130
|
+
manifest.sourceSha === sourceSha &&
|
|
131
|
+
manifest.version === version,
|
|
132
|
+
"candidate identity mismatch",
|
|
133
|
+
);
|
|
134
|
+
requireValue(
|
|
135
|
+
/^[a-z0-9-]+\/[a-z0-9._-]+$/u.test(repository),
|
|
136
|
+
"invalid repository",
|
|
137
|
+
);
|
|
138
|
+
requireValue(
|
|
139
|
+
Array.isArray(manifest.images) && manifest.images.length > 0,
|
|
140
|
+
"empty image family",
|
|
141
|
+
);
|
|
142
|
+
const expected = manifest.expectedImages;
|
|
143
|
+
requireValue(
|
|
144
|
+
Array.isArray(expected) &&
|
|
145
|
+
expected.length === new Set(expected).size &&
|
|
146
|
+
expected.every((n) => namePattern.test(n)),
|
|
147
|
+
"invalid expected image family",
|
|
148
|
+
);
|
|
149
|
+
const actual = manifest.images.map((i) => i.name).sort();
|
|
150
|
+
requireValue(
|
|
151
|
+
JSON.stringify(actual) === JSON.stringify([...expected].sort()),
|
|
152
|
+
"incomplete or duplicate image family",
|
|
153
|
+
);
|
|
154
|
+
const files = new Map();
|
|
155
|
+
for (const image of manifest.images) {
|
|
156
|
+
requireValue(
|
|
157
|
+
image.repository === `ghcr.io/${repository}/${image.name}`,
|
|
158
|
+
"destination must belong to the consumer repository",
|
|
159
|
+
);
|
|
160
|
+
requireValue(
|
|
161
|
+
digestPattern.test(image.digest) &&
|
|
162
|
+
["built", "reused"].includes(image.action),
|
|
163
|
+
"invalid image identity",
|
|
164
|
+
);
|
|
165
|
+
requireValue(
|
|
166
|
+
/^linux\/(amd64|arm64)$/u.test(image.platform),
|
|
167
|
+
"unsupported image platform",
|
|
168
|
+
);
|
|
169
|
+
const prefix = image.layout;
|
|
170
|
+
const descriptorFile = (descriptor) => {
|
|
171
|
+
requireValue(
|
|
172
|
+
digestPattern.test(descriptor.digest) &&
|
|
173
|
+
Number.isSafeInteger(descriptor.size) &&
|
|
174
|
+
descriptor.size >= 0,
|
|
175
|
+
"invalid OCI descriptor",
|
|
176
|
+
);
|
|
177
|
+
const relative = `${prefix}/blobs/sha256/${descriptor.digest.slice(7)}`;
|
|
178
|
+
const file = ociBundleFile(bundleRoot, relative);
|
|
179
|
+
requireValue(
|
|
180
|
+
fs.statSync(file).size === descriptor.size,
|
|
181
|
+
"OCI blob size mismatch",
|
|
182
|
+
);
|
|
183
|
+
if (!files.has(file))
|
|
184
|
+
requireValue(
|
|
185
|
+
fileDigest(file) === descriptor.digest,
|
|
186
|
+
"OCI blob digest mismatch",
|
|
187
|
+
);
|
|
188
|
+
files.set(file, descriptor);
|
|
189
|
+
return file;
|
|
190
|
+
};
|
|
191
|
+
const layout = JSON.parse(
|
|
192
|
+
fs.readFileSync(ociBundleFile(bundleRoot, `${prefix}/oci-layout`)),
|
|
193
|
+
);
|
|
194
|
+
requireValue(
|
|
195
|
+
layout.imageLayoutVersion === "1.0.0",
|
|
196
|
+
"unsupported OCI layout",
|
|
197
|
+
);
|
|
198
|
+
const index = JSON.parse(
|
|
199
|
+
fs.readFileSync(ociBundleFile(bundleRoot, `${prefix}/index.json`)),
|
|
200
|
+
);
|
|
201
|
+
const selected = (index.manifests || []).filter(
|
|
202
|
+
(descriptor) =>
|
|
203
|
+
descriptor.digest === image.digest &&
|
|
204
|
+
(descriptor.annotations?.["org.opencontainers.image.ref.name"] ===
|
|
205
|
+
image.name ||
|
|
206
|
+
index.manifests.length === 1),
|
|
207
|
+
);
|
|
208
|
+
requireValue(
|
|
209
|
+
index.schemaVersion === 2 && selected.length === 1,
|
|
210
|
+
"OCI index must select one exact image manifest",
|
|
211
|
+
);
|
|
212
|
+
const document = JSON.parse(fs.readFileSync(descriptorFile(selected[0])));
|
|
213
|
+
requireValue(
|
|
214
|
+
document.schemaVersion === 2 &&
|
|
215
|
+
[
|
|
216
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
217
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
218
|
+
].includes(document.mediaType) &&
|
|
219
|
+
Array.isArray(document.layers),
|
|
220
|
+
"unsupported OCI image manifest",
|
|
221
|
+
);
|
|
222
|
+
const config = JSON.parse(fs.readFileSync(descriptorFile(document.config)));
|
|
223
|
+
requireValue(
|
|
224
|
+
`${config.os}/${config.architecture}` === image.platform,
|
|
225
|
+
"OCI platform mismatch",
|
|
226
|
+
);
|
|
227
|
+
verifyImageProvenance(image, config, sourceSha, version);
|
|
228
|
+
for (const layer of document.layers) descriptorFile(layer);
|
|
229
|
+
const smokeFile = ociBundleFile(bundleRoot, image.smoke.path);
|
|
230
|
+
requireValue(
|
|
231
|
+
fileDigest(smokeFile) === image.smoke.sha256,
|
|
232
|
+
"smoke evidence digest mismatch",
|
|
233
|
+
);
|
|
234
|
+
const smoke = JSON.parse(fs.readFileSync(smokeFile));
|
|
235
|
+
requireValue(
|
|
236
|
+
smoke.passed === true && smoke.image === image.name,
|
|
237
|
+
"smoke qualification failed",
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return { root: computed, manifest, bundleRoot: fs.realpathSync(bundleRoot) };
|
|
241
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
|
|
@@ -19,7 +20,10 @@ function requiredString(value, label) {
|
|
|
19
20
|
|
|
20
21
|
function safeRelativePath(value, label) {
|
|
21
22
|
const normalized = requiredString(value, label).replaceAll("\\", "/");
|
|
22
|
-
if (
|
|
23
|
+
if (
|
|
24
|
+
normalized.startsWith("/") ||
|
|
25
|
+
normalized.split("/").some((part) => part === ".." || part === "")
|
|
26
|
+
) {
|
|
23
27
|
throw new Error(`${label} must be a safe relative path`);
|
|
24
28
|
}
|
|
25
29
|
return normalized;
|
|
@@ -41,7 +45,8 @@ function sha256File(filePath) {
|
|
|
41
45
|
const chunk = Buffer.allocUnsafe(8 * 1024 * 1024);
|
|
42
46
|
try {
|
|
43
47
|
let bytesRead = 0;
|
|
44
|
-
while ((bytesRead = fs.readSync(descriptor, chunk, 0, chunk.length, null)) > 0)
|
|
48
|
+
while ((bytesRead = fs.readSync(descriptor, chunk, 0, chunk.length, null)) > 0)
|
|
49
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
45
50
|
} finally {
|
|
46
51
|
fs.closeSync(descriptor);
|
|
47
52
|
}
|
|
@@ -60,8 +65,16 @@ function normalizeFile(entry, label) {
|
|
|
60
65
|
};
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
function fileInventory(files, label) {
|
|
69
|
+
return (files || []).map((entry, index) => normalizeFile(entry, `${label}[${index}]`))
|
|
70
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
71
|
+
}
|
|
72
|
+
|
|
63
73
|
function candidatePayload(candidate) {
|
|
64
|
-
if (
|
|
74
|
+
if (
|
|
75
|
+
candidate?.contract !== PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT ||
|
|
76
|
+
Number(candidate?.schemaVersion) !== 1
|
|
77
|
+
) {
|
|
65
78
|
throw new Error("publication artifact candidate contract mismatch");
|
|
66
79
|
}
|
|
67
80
|
const { candidateDigest: _candidateDigest, ...payload } = candidate;
|
|
@@ -91,9 +104,7 @@ export function createPublicationSealedBundle({
|
|
|
91
104
|
githubReleaseRequired = true,
|
|
92
105
|
} = {}) {
|
|
93
106
|
const { digest } = candidatePayload(candidate);
|
|
94
|
-
const files = (candidate.files
|
|
95
|
-
.map((entry, index) => normalizeFile(entry, `candidate.files[${index}]`))
|
|
96
|
-
.sort((left, right) => left.path.localeCompare(right.path));
|
|
107
|
+
const files = fileInventory(candidate.files, "candidate.files");
|
|
97
108
|
if (new Set(files.map((entry) => entry.path)).size !== files.length) {
|
|
98
109
|
throw new Error("publication sealed bundle candidate paths must be unique");
|
|
99
110
|
}
|
|
@@ -119,6 +130,7 @@ export function createPublicationSealedBundle({
|
|
|
119
130
|
sha256: npmTarball.sha256,
|
|
120
131
|
integrity: requiredString(npmIntegrity, "npmIntegrity"),
|
|
121
132
|
},
|
|
133
|
+
...(candidate.npmPackages ? { npmPackages: candidate.npmPackages } : {}),
|
|
122
134
|
releaseAssets,
|
|
123
135
|
completion: {
|
|
124
136
|
githubReleaseRequired: Boolean(githubReleaseRequired),
|
|
@@ -130,8 +142,55 @@ export function createPublicationSealedBundle({
|
|
|
130
142
|
};
|
|
131
143
|
}
|
|
132
144
|
|
|
145
|
+
function verifyNpmPackageSet(manifest, files, resolvedRoot) {
|
|
146
|
+
const npmPackages = manifest.npmPackages;
|
|
147
|
+
if (JSON.stringify(npmPackages) !== JSON.stringify(manifest.candidate.npmPackages)) {
|
|
148
|
+
throw new Error("publication sealed bundle npm package inventory differs from candidate");
|
|
149
|
+
}
|
|
150
|
+
if (npmPackages) {
|
|
151
|
+
if (
|
|
152
|
+
!Array.isArray(npmPackages) ||
|
|
153
|
+
npmPackages.length < 2 ||
|
|
154
|
+
new Set(npmPackages.map((entry) => entry.name)).size !== npmPackages.length ||
|
|
155
|
+
new Set(npmPackages.map((entry) => entry.path)).size !== npmPackages.length
|
|
156
|
+
) {
|
|
157
|
+
throw new Error("publication sealed bundle npm package inventory must be unique");
|
|
158
|
+
}
|
|
159
|
+
for (const entry of npmPackages) {
|
|
160
|
+
const file = selectFile(files, entry.path, "npmPackages.path");
|
|
161
|
+
const absolutePath = path.resolve(resolvedRoot, file.path);
|
|
162
|
+
const metadata = JSON.parse(
|
|
163
|
+
execFileSync("tar", ["-xOf", absolutePath, "package/package.json"], {
|
|
164
|
+
encoding: "utf8",
|
|
165
|
+
}),
|
|
166
|
+
);
|
|
167
|
+
const integrity = `sha512-${crypto.createHash("sha512").update(fs.readFileSync(absolutePath)).digest("base64")}`;
|
|
168
|
+
if (
|
|
169
|
+
entry.name !== metadata.name ||
|
|
170
|
+
entry.version !== metadata.version ||
|
|
171
|
+
entry.size !== file.size ||
|
|
172
|
+
entry.sha256 !== file.sha256 ||
|
|
173
|
+
entry.integrity !== integrity
|
|
174
|
+
) {
|
|
175
|
+
throw new Error("publication sealed bundle npm package identity or integrity mismatch");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const main = npmPackages.filter((entry) => entry.role === "main");
|
|
179
|
+
if (
|
|
180
|
+
main.length !== 1 ||
|
|
181
|
+
Object.keys(manifest.npm).some((key) => main[0][key] !== manifest.npm[key])
|
|
182
|
+
) {
|
|
183
|
+
throw new Error("publication sealed bundle npm main package mismatch");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return npmPackages;
|
|
187
|
+
}
|
|
188
|
+
|
|
133
189
|
export function verifyPublicationSealedBundle({ bundleRoot, manifest } = {}) {
|
|
134
|
-
if (
|
|
190
|
+
if (
|
|
191
|
+
manifest?.contract !== PUBLICATION_SEALED_BUNDLE_CONTRACT ||
|
|
192
|
+
Number(manifest?.schemaVersion) !== 1
|
|
193
|
+
) {
|
|
135
194
|
throw new Error("publication sealed bundle contract mismatch");
|
|
136
195
|
}
|
|
137
196
|
const resolvedRoot = path.resolve(requiredString(bundleRoot, "bundleRoot"));
|
|
@@ -139,12 +198,8 @@ export function verifyPublicationSealedBundle({ bundleRoot, manifest } = {}) {
|
|
|
139
198
|
if (normalizeSha256(manifest.root, "manifest.root") !== digest) {
|
|
140
199
|
throw new Error("publication sealed bundle root mismatch");
|
|
141
200
|
}
|
|
142
|
-
const files = (manifest.files
|
|
143
|
-
|
|
144
|
-
.sort((left, right) => left.path.localeCompare(right.path));
|
|
145
|
-
const candidateFiles = (manifest.candidate.files || [])
|
|
146
|
-
.map((entry, index) => normalizeFile(entry, `candidate.files[${index}]`))
|
|
147
|
-
.sort((left, right) => left.path.localeCompare(right.path));
|
|
201
|
+
const files = fileInventory(manifest.files, "manifest.files");
|
|
202
|
+
const candidateFiles = fileInventory(manifest.candidate.files, "candidate.files");
|
|
148
203
|
if (JSON.stringify(files) !== JSON.stringify(candidateFiles)) {
|
|
149
204
|
throw new Error("publication sealed bundle file inventory differs from candidate");
|
|
150
205
|
}
|
|
@@ -169,6 +224,7 @@ export function verifyPublicationSealedBundle({ bundleRoot, manifest } = {}) {
|
|
|
169
224
|
) {
|
|
170
225
|
throw new Error("publication sealed bundle npm tarball inventory mismatch");
|
|
171
226
|
}
|
|
227
|
+
const npmPackages = verifyNpmPackageSet(manifest, files, resolvedRoot);
|
|
172
228
|
const releaseAssets = (manifest.releaseAssets || []).map((entry, index) => {
|
|
173
229
|
const selected = selectFile(files, entry.path, `manifest.releaseAssets[${index}].path`);
|
|
174
230
|
const normalized = normalizeFile(entry, `manifest.releaseAssets[${index}]`);
|
|
@@ -194,6 +250,14 @@ export function verifyPublicationSealedBundle({ bundleRoot, manifest } = {}) {
|
|
|
194
250
|
...entry,
|
|
195
251
|
absolutePath: path.resolve(resolvedRoot, entry.path),
|
|
196
252
|
})),
|
|
253
|
+
...(npmPackages
|
|
254
|
+
? {
|
|
255
|
+
npmPackages: npmPackages.map((entry) => ({
|
|
256
|
+
...entry,
|
|
257
|
+
absolutePath: path.resolve(resolvedRoot, entry.path),
|
|
258
|
+
})),
|
|
259
|
+
}
|
|
260
|
+
: {}),
|
|
197
261
|
manifest,
|
|
198
262
|
};
|
|
199
263
|
}
|
|
@@ -17,6 +17,15 @@ export const RELEASE_TAIL_PRODUCT_CAPABILITIES = Object.freeze([
|
|
|
17
17
|
receiptKind: "product-package-publication",
|
|
18
18
|
transactionState: "publishing",
|
|
19
19
|
}),
|
|
20
|
+
Object.freeze({
|
|
21
|
+
id: "product.oci.publish",
|
|
22
|
+
executor: "provider-adapter",
|
|
23
|
+
adapter: "oci-image-family",
|
|
24
|
+
effectKind: "oci-family-publication",
|
|
25
|
+
observationKind: "oci-family-readback",
|
|
26
|
+
receiptKind: "oci-family-publication",
|
|
27
|
+
transactionState: "publishing",
|
|
28
|
+
}),
|
|
20
29
|
Object.freeze({
|
|
21
30
|
id: "product.release-refs.converge",
|
|
22
31
|
executor: "provider-adapter",
|
|
@@ -58,6 +58,7 @@ const ROOT_DOMAINS = new Set([
|
|
|
58
58
|
"partial-mutation-recovery-checkpoint",
|
|
59
59
|
"partial-mutation-recovery-plan",
|
|
60
60
|
"v4-product-required-artifacts",
|
|
61
|
+
"oci-publication-family",
|
|
61
62
|
"v4-product-publication-intent",
|
|
62
63
|
"v4-product-publication-operation",
|
|
63
64
|
"v4-product-publication-plan",
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
export const V4_DOMAIN_WASM_ABI_VERSION = 1;
|
|
3
3
|
export const V4_DOMAIN_WASM_RUSTC = "rustc 1.96.0 (ac68faa20 2026-05-25)";
|
|
4
4
|
export const V4_DOMAIN_WASM_SOURCE_SHA256 =
|
|
5
|
-
"
|
|
5
|
+
"bec17d9c47ab0ab8bdc379827f4bfb98fc95d41ee538c0558be538b86e06c775";
|
|
6
6
|
export const V4_DOMAIN_WASM_SHA256 =
|
|
7
|
-
"
|
|
7
|
+
"76600d4a9cfba5ac318c9b3be69e2a4e304faee714d2e72b1fba288f945c09f0";
|
|
@@ -13,6 +13,7 @@ export function selectV4ProductPublicationIntent({
|
|
|
13
13
|
repository,
|
|
14
14
|
artifactKind = "npm",
|
|
15
15
|
packageName,
|
|
16
|
+
npmPackages,
|
|
16
17
|
distTag,
|
|
17
18
|
sealedBundleRoot,
|
|
18
19
|
requiredArtifactsRoot,
|
|
@@ -27,6 +28,7 @@ export function selectV4ProductPublicationIntent({
|
|
|
27
28
|
sourceTimestamp,
|
|
28
29
|
repository,
|
|
29
30
|
artifactKind,
|
|
31
|
+
...(npmPackages === undefined ? {} : { npmPackages }),
|
|
30
32
|
...(packageName === undefined ? {} : { packageName }),
|
|
31
33
|
...(distTag === undefined ? {} : { distTag }),
|
|
32
34
|
...(sealedBundleRoot === undefined ? {} : { sealedBundleRoot }),
|
|
@@ -233,13 +233,21 @@ function workflowSnapshot(relative) {
|
|
|
233
233
|
.map((job) => {
|
|
234
234
|
const block = jobBlock(source, job.id);
|
|
235
235
|
const uses = job.uses || null;
|
|
236
|
+
// A built-in publisher still requires mutation authority when the caller supplies its envelope.
|
|
237
|
+
const inheritedPublisher =
|
|
238
|
+
!/^ permissions:/mu.test(block) &&
|
|
239
|
+
/uses:.*actions\/v4-release-candidate-promote/u.test(block);
|
|
236
240
|
return {
|
|
237
241
|
id: job.id,
|
|
238
242
|
kind: uses ? "reusable-call" : "runner",
|
|
239
243
|
uses,
|
|
240
244
|
permissions: {
|
|
241
|
-
contents:
|
|
242
|
-
|
|
245
|
+
contents:
|
|
246
|
+
permission(block, "contents") ||
|
|
247
|
+
(inheritedPublisher ? "inherited" : null),
|
|
248
|
+
idToken:
|
|
249
|
+
permission(block, "id-token") ||
|
|
250
|
+
(inheritedPublisher ? "inherited" : null),
|
|
243
251
|
},
|
|
244
252
|
carriers: {
|
|
245
253
|
artifactDownload: /uses:\s+actions\/download-artifact@/u.test(block),
|
|
@@ -247,8 +255,10 @@ function workflowSnapshot(relative) {
|
|
|
247
255
|
jobOutput: /GITHUB_OUTPUT/u.test(block),
|
|
248
256
|
},
|
|
249
257
|
mutationSignals: [
|
|
250
|
-
/contents:\s*write/u.test(block) &&
|
|
251
|
-
|
|
258
|
+
(/contents:\s*write/u.test(block) || inheritedPublisher) &&
|
|
259
|
+
"contents-write",
|
|
260
|
+
(/id-token:\s*write/u.test(block) || inheritedPublisher) &&
|
|
261
|
+
"oidc-write",
|
|
252
262
|
/(?:promote-buildchain-ref|v4-release-candidate-promote)/u.test(
|
|
253
263
|
block,
|
|
254
264
|
) && "promotion-runtime",
|
|
@@ -316,16 +316,7 @@ ${secrets.trimEnd()}
|
|
|
316
316
|
outputs:
|
|
317
317
|
${publicOutputs(outputs)}
|
|
318
318
|
|
|
319
|
-
|
|
320
|
-
actions: write
|
|
321
|
-
artifact-metadata: write
|
|
322
|
-
attestations: write
|
|
323
|
-
checks: write
|
|
324
|
-
contents: write
|
|
325
|
-
id-token: write
|
|
326
|
-
issues: write
|
|
327
|
-
pull-requests: write
|
|
328
|
-
|
|
319
|
+
# Provider authority is inherited from the explicit caller envelope.
|
|
329
320
|
jobs:
|
|
330
321
|
resolve-promotion:
|
|
331
322
|
name: Resolve promotion workflow shell and runtime
|
|
@@ -503,15 +494,7 @@ ${consumerAdmissionJob()}
|
|
|
503
494
|
name: Invoke the single v4 publisher adapter
|
|
504
495
|
needs: [resolve-promotion, consumer-admission]
|
|
505
496
|
uses: kungfu-systems/buildchain/${alphaRoute.workflowPath}@${alphaRoute.callRef}
|
|
506
|
-
|
|
507
|
-
actions: write
|
|
508
|
-
artifact-metadata: write
|
|
509
|
-
attestations: write
|
|
510
|
-
checks: write
|
|
511
|
-
contents: write
|
|
512
|
-
id-token: write
|
|
513
|
-
issues: write
|
|
514
|
-
pull-requests: write
|
|
497
|
+
# Provider authority is inherited from the explicit caller envelope.
|
|
515
498
|
with:
|
|
516
499
|
${invokeForwarded}
|
|
517
500
|
secrets: inherit
|
|
@@ -216,11 +216,17 @@ function applyPublicationFacadePatches(source, relative) {
|
|
|
216
216
|
return source;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
function inheritPublicationProviderAuthority(source, relative) {
|
|
220
|
+
if (relative !== ".github/workflows/.release-candidate-promote.yml") return source;
|
|
221
|
+
return source.replace("permissions:\n contents: read\n", "# Provider authority is inherited from the explicit caller envelope.\n")
|
|
222
|
+
.replace(" permissions:\n actions: read\n checks: write\n contents: write\n id-token: write\n pull-requests: write\n", " # Provider authority is inherited from the explicit caller envelope.\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
219
225
|
export function migrateV4UniversalWorkflowFacade(source, relative) {
|
|
220
|
-
return rewriteRepositoryWorkflowPaths(root, applyPublicationFacadePatches(guardCompatibilityJobs(
|
|
226
|
+
return rewriteRepositoryWorkflowPaths(root, inheritPublicationProviderAuthority(applyPublicationFacadePatches(guardCompatibilityJobs(
|
|
221
227
|
addUniversalInput(addRuntimeBootstrapDependencies(source, relative), relative),
|
|
222
228
|
relative,
|
|
223
|
-
), relative));
|
|
229
|
+
), relative), relative));
|
|
224
230
|
}
|
|
225
231
|
|
|
226
232
|
function verify(source, relative) {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { verifyOciPublicationBundle } from "../packages/core/oci-publication-bundle.js";
|
|
4
|
+
|
|
5
|
+
export function resolveOciCandidate({ payloadRoot, passport }) {
|
|
6
|
+
const matches = [];
|
|
7
|
+
function visit(directory) {
|
|
8
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
9
|
+
if (entry.isSymbolicLink())
|
|
10
|
+
throw new Error("OCI candidate cannot contain symlinks");
|
|
11
|
+
const file = path.join(directory, entry.name);
|
|
12
|
+
if (entry.isDirectory()) visit(file);
|
|
13
|
+
else if (entry.name === "oci-family.json") matches.push(file);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
visit(payloadRoot);
|
|
17
|
+
if (matches.length !== 1)
|
|
18
|
+
throw new Error("OCI candidate requires exactly one sealed image family");
|
|
19
|
+
const manifest = JSON.parse(fs.readFileSync(matches[0]));
|
|
20
|
+
const bundleRoot = path.dirname(matches[0]);
|
|
21
|
+
const verified = verifyOciPublicationBundle({
|
|
22
|
+
bundleRoot,
|
|
23
|
+
manifest,
|
|
24
|
+
repository: passport.repository,
|
|
25
|
+
sourceSha: passport.source.headSha,
|
|
26
|
+
version: passport.target.version,
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
root: bundleRoot,
|
|
30
|
+
manifest: verified.manifest,
|
|
31
|
+
requiredArtifacts: manifest.images.map((image) => ({
|
|
32
|
+
kind: "oci",
|
|
33
|
+
name: image.repository,
|
|
34
|
+
ref: `v${manifest.version}`,
|
|
35
|
+
digest: image.digest,
|
|
36
|
+
platform: image.platform,
|
|
37
|
+
required: true,
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function resolveRecoveredPublicationVersion({
|
|
43
|
+
artifactVersion,
|
|
44
|
+
channel,
|
|
45
|
+
rematerializeOnResume = false,
|
|
46
|
+
} = {}) {
|
|
47
|
+
const version = String(artifactVersion || "").trim(),
|
|
48
|
+
match = version.match(
|
|
49
|
+
/^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u,
|
|
50
|
+
);
|
|
51
|
+
if (!match)
|
|
52
|
+
throw new Error(
|
|
53
|
+
`recovered npm artifact has invalid publication version: ${version || "<empty>"}`,
|
|
54
|
+
);
|
|
55
|
+
return channel === "release" && rematerializeOnResume ? match[1] : version;
|
|
56
|
+
}
|
|
57
|
+
export function resolveRecoveredCandidateVersion({
|
|
58
|
+
artifactVersion,
|
|
59
|
+
publicationVersion,
|
|
60
|
+
channel,
|
|
61
|
+
rematerializeOnResume = false,
|
|
62
|
+
targetRef = "",
|
|
63
|
+
candidateRef = "",
|
|
64
|
+
} = {}) {
|
|
65
|
+
const version = String(artifactVersion || "").trim(),
|
|
66
|
+
target = String(targetRef || "").replace(/^refs\/heads\//u, ""),
|
|
67
|
+
candidate = String(candidateRef || "").replace(/^refs\/heads\//u, "");
|
|
68
|
+
if (channel !== "release" || !rematerializeOnResume) return version;
|
|
69
|
+
const prefix = `publish-gate/${target}/`;
|
|
70
|
+
if (!target || !candidate.startsWith(prefix))
|
|
71
|
+
throw new Error(
|
|
72
|
+
`stable recovery candidate ref must descend from ${prefix || "publish-gate/<target>/"}`,
|
|
73
|
+
);
|
|
74
|
+
const candidateVersion = candidate.slice(prefix.length);
|
|
75
|
+
if (!/^\d+\.\d+\.\d+-alpha\.\d+$/u.test(candidateVersion))
|
|
76
|
+
throw new Error(
|
|
77
|
+
`stable recovery candidate ref must bind an exact alpha version, got ${candidateVersion || "<empty>"}`,
|
|
78
|
+
);
|
|
79
|
+
if (candidateVersion.replace(/-alpha\.\d+$/u, "") !== publicationVersion)
|
|
80
|
+
throw new Error(
|
|
81
|
+
`stable recovery candidate ${candidateVersion} does not match publication ${publicationVersion || "<empty>"}`,
|
|
82
|
+
);
|
|
83
|
+
if (
|
|
84
|
+
(version.match(
|
|
85
|
+
/^(\d+\.\d+\.\d+)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u,
|
|
86
|
+
)?.[1] || "") !== publicationVersion
|
|
87
|
+
)
|
|
88
|
+
throw new Error(
|
|
89
|
+
`recovered npm artifact version ${version || "<empty>"} does not match publication ${publicationVersion}`,
|
|
90
|
+
);
|
|
91
|
+
return candidateVersion;
|
|
92
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveOciCandidate } from "./publication-candidate-kind.mjs";
|
|
1
2
|
import crypto from "node:crypto";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
@@ -69,7 +70,7 @@ export function createResolvedPublicationSealedBundle({
|
|
|
69
70
|
});
|
|
70
71
|
const main = resolvedNpmArtifacts.find((entry) => entry.metadata.role === "main")
|
|
71
72
|
|| (resolvedNpmArtifacts.length === 1 ? resolvedNpmArtifacts[0] : undefined);
|
|
72
|
-
if (!main) throw new Error("candidate npm payload set has no unique main package tarball");
|
|
73
|
+
if (!main || resolvedNpmArtifacts.filter((entry) => entry.metadata.role === "main").length > 1) throw new Error("candidate npm payload set has no unique main package tarball");
|
|
73
74
|
const selectedReleaseAssets = (releaseAssetPaths.length > 0
|
|
74
75
|
? releaseAssetPaths
|
|
75
76
|
: resolvedNpmArtifacts.map((entry) => entry.file.absolutePath))
|
|
@@ -88,6 +89,12 @@ export function createResolvedPublicationSealedBundle({
|
|
|
88
89
|
releaseCandidateRoot: `sha256:${normalizeSha256(releaseCandidateRoot, "releaseCandidateRoot")}`,
|
|
89
90
|
files: files.map(({ path: filePath, size, sha256 }) => ({ path: filePath, size, sha256 })),
|
|
90
91
|
};
|
|
92
|
+
if (resolvedNpmArtifacts.length > 1) {
|
|
93
|
+
candidatePayload.npmPackages = resolvedNpmArtifacts.map(({file, metadata}) => ({
|
|
94
|
+
name: metadata.name, version: metadata.ref, role: metadata.role,
|
|
95
|
+
path: file.path, size: file.size, sha256: file.sha256, integrity: metadata.integrity,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
91
98
|
const candidate = {
|
|
92
99
|
...candidatePayload,
|
|
93
100
|
candidateDigest: publicationArtifactCandidateDigest(candidatePayload),
|
|
@@ -102,3 +109,11 @@ export function createResolvedPublicationSealedBundle({
|
|
|
102
109
|
});
|
|
103
110
|
return { root: resolvedRoot, manifest, npmArtifacts: resolvedNpmArtifacts, files };
|
|
104
111
|
}
|
|
112
|
+
|
|
113
|
+
export function resolveCandidatePublicationBundle({ kind, payloadDir, passport, runtimeSha, npmArtifacts, releaseAssetPaths }) {
|
|
114
|
+
if (kind === "oci") return resolveOciCandidate({ payloadRoot: payloadDir, passport });
|
|
115
|
+
if (kind !== "npm") return undefined;
|
|
116
|
+
return createResolvedPublicationSealedBundle({ bundleRoot: payloadDir, repository: passport.repository,
|
|
117
|
+
sourceSha: passport.source?.headSha, sourceTreeSha: passport.source?.treeHash,
|
|
118
|
+
runtimeSha, releaseCandidateRoot: passport.candidateHash, npmArtifacts, releaseAssetPaths });
|
|
119
|
+
}
|