@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.
Files changed (42) hide show
  1. package/architecture/agent-change-map.md +1 -1
  2. package/architecture/maintainability-debt.json +24 -25
  3. package/architecture/maintainability-policy.json +4 -4
  4. package/architecture/v4-release-topology.json +9 -6
  5. package/architecture/v4-universal-workflow-train-admission.json +1 -1
  6. package/dist/site/buildchain-contract.json +7 -7
  7. package/dist/site/buildchain-site.json +55 -14
  8. package/dist/site/capability-registry.json +2 -2
  9. package/dist/site/kfd-claims.json +29 -5
  10. package/dist/site/kfd-upstream-aggregate.json +1 -1
  11. package/dist/site/manual-registry.json +3 -3
  12. package/dist/site/node-api-registry.json +154 -13
  13. package/dist/site/page-registry.json +48 -7
  14. package/dist/site/public-surface-audit.json +7 -3
  15. package/dist/site/publication-authority-registry.json +5 -17
  16. package/dist/site/publication-registry.json +4 -4
  17. package/dist/site/release-provenance.json +2 -1
  18. package/dist/site/site-manifest.json +7 -7
  19. package/dist/site/workflow-registry.json +5 -5
  20. package/docs/MAP.md +1 -0
  21. package/docs/node-api-reference.md +26 -15
  22. package/docs/oci-publication.md +82 -0
  23. package/docs/publish-transaction.md +36 -3
  24. package/package.json +3 -2
  25. package/packages/core/buildchain-v4-domain.wasm +0 -0
  26. package/packages/core/next-development-transition.js +5 -6
  27. package/packages/core/oci-publication-bundle.js +241 -0
  28. package/packages/core/publication-sealed-bundle.js +77 -13
  29. package/packages/core/release-tail-product-capabilities.js +9 -0
  30. package/packages/core/v4-canonical-contracts.js +1 -0
  31. package/packages/core/v4-domain-wasm-artifact.js +2 -2
  32. package/packages/core/v4-product-publication.js +2 -0
  33. package/scripts/check-v4-release-topology.mjs +14 -4
  34. package/scripts/generate-channel-promotion-workflow.mjs +2 -19
  35. package/scripts/generate-v4-universal-workflow-facades.mjs +8 -2
  36. package/scripts/publication-candidate-kind.mjs +92 -0
  37. package/scripts/publication-candidate-sealer.mjs +16 -1
  38. package/scripts/release-candidate-resolver.mjs +6 -17
  39. package/scripts/resume-from-candidate-run.mjs +10 -17
  40. package/scripts/site-capability-metadata.mjs +1 -0
  41. package/scripts/v4-product-publication-intent.mjs +9 -1
  42. 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 (normalized.startsWith("/") || normalized.split("/").some((part) => part === ".." || part === "")) {
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) hash.update(chunk.subarray(0, bytesRead));
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 (candidate?.contract !== PUBLICATION_ARTIFACT_CANDIDATE_CONTRACT || Number(candidate?.schemaVersion) !== 1) {
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 (manifest?.contract !== PUBLICATION_SEALED_BUNDLE_CONTRACT || Number(manifest?.schemaVersion) !== 1) {
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
- .map((entry, index) => normalizeFile(entry, `manifest.files[${index}]`))
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
- "07d45b2767e22007d3d41fa32c8a2dba28d9c88c11a2b28863e82d54be3a2933";
5
+ "bec17d9c47ab0ab8bdc379827f4bfb98fc95d41ee538c0558be538b86e06c775";
6
6
  export const V4_DOMAIN_WASM_SHA256 =
7
- "b37c330e9366b7e9cf9dc4b7573b1e9495212dd9520e8b8ec6cdc3fd0ab7cafd";
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: permission(block, "contents"),
242
- idToken: permission(block, "id-token"),
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) && "contents-write",
251
- /id-token:\s*write/u.test(block) && "oidc-write",
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
- permissions:
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
- permissions:
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
+ }