@kungfu-tech/buildchain 3.0.5-alpha.6 → 3.0.5-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +31 -0
  2. package/bin/buildchain.mjs +1 -1
  3. package/contracts/auditable-demo-media-profiles-v1.json +61 -0
  4. package/contracts/auditable-demo-scenario-v1.schema.json +153 -0
  5. package/contracts/evidence/auditable-demo-responsive-web-delivery-v1.json +3 -3
  6. package/contracts/evidence/auditable-demo-web-delivery-v1.json +3 -3
  7. package/dist/site/buildchain-contract.json +57 -6
  8. package/dist/site/buildchain-site.json +38 -22
  9. package/dist/site/capability-registry.json +1 -1
  10. package/dist/site/cli-registry.json +6 -2
  11. package/dist/site/kfd-claims.json +49 -9
  12. package/dist/site/kfd-upstream-aggregate.json +1 -1
  13. package/dist/site/manual-registry.json +3 -3
  14. package/dist/site/node-api-registry.json +203 -21
  15. package/dist/site/page-registry.json +28 -8
  16. package/dist/site/public-surface-audit.json +94 -6
  17. package/dist/site/publication-authority-registry.json +42 -1
  18. package/dist/site/publication-registry.json +4 -4
  19. package/dist/site/site-manifest.json +7 -7
  20. package/dist/site/workflow-registry.json +63 -0
  21. package/docs/auditable-demo.md +74 -7
  22. package/docs/cli-reference.md +24 -2
  23. package/docs/node-api-reference.md +63 -45
  24. package/package.json +1 -1
  25. package/packages/core/buildchain-contract.js +36 -1
  26. package/packages/core/buildchain-publication-authority.js +2 -0
  27. package/packages/core/release-propagation-pickup.js +386 -0
  28. package/packages/core/release-propagation-release.js +61 -18
  29. package/packages/core/release-propagation.js +12 -2
  30. package/scripts/auditable-demo-platform.mjs +506 -0
  31. package/scripts/auditable-demo-renditions.mjs +6 -1
  32. package/scripts/auditable-demo.mjs +14 -4
  33. package/scripts/build-standalone-binary.mjs +4 -0
  34. package/scripts/buildchain-cli-help.mjs +2 -1
  35. package/scripts/check-inventory.mjs +5 -0
  36. package/scripts/generate-site-bundle.mjs +2 -0
  37. package/scripts/release-propagation.mjs +77 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "3.0.5-alpha.6",
3
+ "version": "3.0.5-alpha.7",
4
4
  "private": false,
5
5
  "description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -72,6 +72,41 @@ function majorLineFromPackageVersion(version = "") {
72
72
  return match ? `v${match[1]}` : "v3";
73
73
  }
74
74
 
75
+ function declarativeAuditableDemoSurface(root, pkg, majorLine) {
76
+ return surface(root, {
77
+ id: "declarative-auditable-demo",
78
+ kind: "workflow",
79
+ path: ".github/workflows/.declarative-auditable-demo.yml",
80
+ publicRef: `${pkg.repository ? "kungfu-systems/buildchain" : "buildchain"}/.github/workflows/.declarative-auditable-demo.yml@${majorLine}`,
81
+ requiredInputs: ["binary-artifact-name", "binary-artifact-digest", "renderer-image"],
82
+ requiredOutputs: [
83
+ "source-sha", "capture-artifact-name", "capture-artifact-digest",
84
+ "evidence-artifact-name", "evidence-artifact-digest", "publication-pr-url",
85
+ ],
86
+ breakingDefaults: {
87
+ scenarioPathDefault: ".buildchain/auditable-demo.json",
88
+ renderMediaDefault: false,
89
+ materializeDefault: false,
90
+ mediaProfileDefault: "responsive-web-delivery-v1",
91
+ artifactRetentionDaysDefault: 14,
92
+ executionBoundary: "exact-binary-network-none-secret-free-60-seconds",
93
+ },
94
+ optionalInputs: [
95
+ "buildchain-repository", "source-ref", "scenario-path", "render-media",
96
+ "media-profile", "materialize", "materialize-base-ref", "artifact-retention-days",
97
+ ],
98
+ guarantees: [
99
+ "one versioned declaration can contain multiple demos with multiple ordered literal argv steps",
100
+ "every demo uses the exact same-run standalone binary admitted by producer-owned artifact name and digest",
101
+ "capture runs without network or inherited secrets and retains independent native 1080p and 720p terminal dimensions",
102
+ "the generic adapter feeds the required auditable demo Gate and immutable renderer without product-specific glue",
103
+ "full rendering emits a content-addressed Release Passport and idempotent protected README update pull request",
104
+ "manual, alpha, and release callers use the same capture, Gate, renderer, passport, and materializer implementation",
105
+ "identity, compliance, Product System metadata, package metadata, registry history, scans, and generation grant no authority",
106
+ ],
107
+ });
108
+ }
109
+
75
110
  export function createBuildchainContractWorld({
76
111
  root = process.cwd(),
77
112
  packageJson = undefined,
@@ -360,7 +395,7 @@ export function createBuildchainContractWorld({
360
395
  "Build Images owns encoding, Buildchain owns qualification and receipts, and site repositories own browser loading and accessibility behavior",
361
396
  "media qualification does not claim browser playback, responsive layout, reduced-motion behavior, accessibility, or production deployment",
362
397
  ],
363
- }),
398
+ }), declarativeAuditableDemoSurface(root, pkg, majorLine),
364
399
  surface(root, {
365
400
  id: "promote-buildchain-ref-action",
366
401
  kind: "action",
@@ -5,6 +5,7 @@ import { createPublicationAuthorityRegistry } from "./publication-authority.js";
5
5
 
6
6
  const DESCRIPTORS = Object.freeze([
7
7
  [".github/workflows/.auditable-demo.yml", "non-publication-oidc"],
8
+ [".github/workflows/.declarative-auditable-demo.yml", "governance-write"],
8
9
  [".github/workflows/.build.yml", "non-publication-oidc"],
9
10
  [".github/workflows/.release-candidate-promote.yml", "product-publication", true, ["npm-publish", "github-release", "channel-ref"], "trusted-publishing", "none", "fixed", "caller-bound"],
10
11
  [".github/workflows/.publication-authority.yml", "evidence-publication"],
@@ -19,6 +20,7 @@ const DESCRIPTORS = Object.freeze([
19
20
  [".github/workflows/binary-distribution.yml", "evidence-publication"],
20
21
  [".github/workflows/binary-release-assets.yml", "governance-write"],
21
22
  [".github/workflows/artifact-signing-authority.yml", "evidence-publication"],
23
+ [".github/workflows/auditable-demo.yml", "governance-write"],
22
24
  [".github/workflows/build-surface-fixture.yml", "non-publication-oidc"],
23
25
  [".github/workflows/build.yml", "non-publication-oidc"],
24
26
  [".github/workflows/buildchain-alpha-self-dogfood.yml", "non-publication-oidc"],
@@ -0,0 +1,386 @@
1
+ import {
2
+ assertExactFields,
3
+ assertPlainObject,
4
+ assertString,
5
+ normalizeChannel,
6
+ optionalString,
7
+ } from "./release-propagation-common.js";
8
+ import { normalizeExecutionProfile } from "./release-propagation-execution-profile.js";
9
+ import { assertCommitSha } from "./release-propagation-work-control.js";
10
+ import {
11
+ createReleasePropagationWork,
12
+ planReleasePropagation,
13
+ } from "./release-propagation.js";
14
+ import { normalizeUpstreamRelease } from "./release-propagation-release.js";
15
+
16
+ export const MANUAL_UPSTREAM_PICKUP_CONFIG_CONTRACT =
17
+ "kungfu-buildchain-manual-upstream-pickup";
18
+ export const MANUAL_UPSTREAM_PICKUP_PLAN_CONTRACT =
19
+ "kungfu-buildchain-manual-upstream-pickup-plan";
20
+
21
+ function assertRepository(value, label) {
22
+ const repository = assertString(value, label);
23
+ if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) {
24
+ throw new Error(`${label} must be owner/repo`);
25
+ }
26
+ return repository;
27
+ }
28
+
29
+ function normalizeStringList(values, label) {
30
+ if (!Array.isArray(values) || values.length === 0) {
31
+ throw new Error(`${label} must be a non-empty array`);
32
+ }
33
+ const normalized = values.map((value, index) =>
34
+ assertString(value, `${label}[${index}]`),
35
+ );
36
+ const canonical = [...new Set(normalized)].sort();
37
+ if (JSON.stringify(normalized) !== JSON.stringify(canonical)) {
38
+ throw new Error(`${label} must be sorted and unique`);
39
+ }
40
+ return canonical;
41
+ }
42
+
43
+ function normalizeRuntime(value) {
44
+ const runtime = assertExactFields(
45
+ value,
46
+ ["package", "version"],
47
+ "manual pickup runtime",
48
+ );
49
+ return {
50
+ package: assertString(runtime.package, "manual pickup runtime.package"),
51
+ version: assertString(runtime.version, "manual pickup runtime.version"),
52
+ };
53
+ }
54
+
55
+ function normalizeDownstream(value) {
56
+ const downstream = assertExactFields(
57
+ value,
58
+ ["id", "repository", "baseRef", "executionProfile"],
59
+ "manual pickup downstream",
60
+ );
61
+ return {
62
+ id: assertString(downstream.id, "manual pickup downstream.id"),
63
+ repository: assertRepository(
64
+ downstream.repository,
65
+ "manual pickup downstream.repository",
66
+ ),
67
+ baseRef: assertString(
68
+ downstream.baseRef,
69
+ "manual pickup downstream.baseRef",
70
+ ),
71
+ executionProfile: normalizeExecutionProfile(
72
+ downstream.executionProfile,
73
+ "manual pickup downstream.executionProfile",
74
+ ),
75
+ };
76
+ }
77
+
78
+ function normalizeSource(value, index) {
79
+ const label = `manual pickup sources[${index}]`;
80
+ const source = assertExactFields(
81
+ value,
82
+ [
83
+ "id",
84
+ "repository",
85
+ "package",
86
+ "lockPath",
87
+ "distTags",
88
+ "workflowPaths",
89
+ "workflowRefs",
90
+ ],
91
+ label,
92
+ );
93
+ const distTags = assertExactFields(
94
+ source.distTags,
95
+ ["alpha", "release"],
96
+ `${label}.distTags`,
97
+ );
98
+ return {
99
+ id: assertString(source.id, `${label}.id`),
100
+ repository: assertRepository(source.repository, `${label}.repository`),
101
+ package: assertString(source.package, `${label}.package`),
102
+ lockPath: assertString(source.lockPath, `${label}.lockPath`),
103
+ distTags: {
104
+ alpha: assertString(distTags.alpha, `${label}.distTags.alpha`),
105
+ release: assertString(distTags.release, `${label}.distTags.release`),
106
+ },
107
+ workflowPaths: normalizeStringList(
108
+ source.workflowPaths,
109
+ `${label}.workflowPaths`,
110
+ ),
111
+ workflowRefs: normalizeStringList(
112
+ source.workflowRefs,
113
+ `${label}.workflowRefs`,
114
+ ),
115
+ };
116
+ }
117
+
118
+ export function normalizeManualUpstreamPickupConfig(input) {
119
+ const config = assertExactFields(
120
+ input,
121
+ ["schemaVersion", "contract", "runtime", "downstream", "sources"],
122
+ "manual upstream pickup config",
123
+ );
124
+ if (
125
+ config.schemaVersion !== 1 ||
126
+ config.contract !== MANUAL_UPSTREAM_PICKUP_CONFIG_CONTRACT
127
+ ) {
128
+ throw new Error(
129
+ `manual upstream pickup config must use ${MANUAL_UPSTREAM_PICKUP_CONFIG_CONTRACT} v1`,
130
+ );
131
+ }
132
+ if (!Array.isArray(config.sources) || config.sources.length === 0) {
133
+ throw new Error(
134
+ "manual upstream pickup config sources must be a non-empty array",
135
+ );
136
+ }
137
+ const sources = config.sources.map(normalizeSource);
138
+ const ids = sources.map((source) => source.id);
139
+ if (new Set(ids).size !== ids.length)
140
+ throw new Error("manual pickup source ids must be unique");
141
+ return {
142
+ schemaVersion: 1,
143
+ contract: MANUAL_UPSTREAM_PICKUP_CONFIG_CONTRACT,
144
+ runtime: normalizeRuntime(config.runtime),
145
+ downstream: normalizeDownstream(config.downstream),
146
+ sources,
147
+ };
148
+ }
149
+
150
+ function decodePayload(attestation, label) {
151
+ const encoded = attestation?.bundle?.dsseEnvelope?.payload;
152
+ if (!encoded) throw new Error(`${label} is missing a DSSE payload`);
153
+ try {
154
+ return JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
155
+ } catch {
156
+ throw new Error(`${label} DSSE payload is not valid JSON`);
157
+ }
158
+ }
159
+
160
+ function repositoryFromUrl(value) {
161
+ return String(value || "")
162
+ .replace(/^git\+/, "")
163
+ .replace(/^https:\/\/github\.com\//, "")
164
+ .replace(/\.git$/, "");
165
+ }
166
+
167
+ export function resolveNpmRegistryRelease({
168
+ source: sourceInput,
169
+ channel,
170
+ packageMetadata,
171
+ attestations,
172
+ }) {
173
+ const source = normalizeSource(sourceInput, 0);
174
+ const normalizedChannel = normalizeChannel(channel, "manual pickup channel");
175
+ const metadata = assertPlainObject(packageMetadata, "npm package metadata");
176
+ if (metadata.name !== source.package)
177
+ throw new Error("npm package name disagrees with manual pickup source");
178
+ if (
179
+ repositoryFromUrl(metadata.repository?.url || metadata.repository) !==
180
+ source.repository
181
+ ) {
182
+ throw new Error(
183
+ "npm package repository disagrees with manual pickup source",
184
+ );
185
+ }
186
+ const version = assertString(
187
+ metadata.version,
188
+ "npm package metadata.version",
189
+ );
190
+ const integrity = assertString(
191
+ metadata.dist?.integrity,
192
+ "npm package metadata.dist.integrity",
193
+ );
194
+ const attestationUrl = assertString(
195
+ metadata.dist?.attestations?.url,
196
+ "npm package metadata.dist.attestations.url",
197
+ );
198
+ const response = assertPlainObject(attestations, "npm attestations response");
199
+ const slsa = (response.attestations || []).find(
200
+ (entry) => entry.predicateType === "https://slsa.dev/provenance/v1",
201
+ );
202
+ if (!slsa) throw new Error("npm package does not expose SLSA provenance v1");
203
+ const statement = decodePayload(slsa, "npm SLSA provenance");
204
+ if (statement.predicateType !== "https://slsa.dev/provenance/v1") {
205
+ throw new Error(
206
+ "npm SLSA statement predicate type disagrees with its envelope",
207
+ );
208
+ }
209
+ const expectedSubject = `pkg:npm/${encodeURIComponent(source.package)}@${version}`;
210
+ const subject = (statement.subject || []).find(
211
+ (entry) => entry.name === expectedSubject,
212
+ );
213
+ if (!subject?.digest?.sha512)
214
+ throw new Error("npm SLSA provenance is missing the exact package subject");
215
+ const workflow =
216
+ statement.predicate?.buildDefinition?.externalParameters?.workflow || {};
217
+ const workflowRepository = repositoryFromUrl(workflow.repository);
218
+ if (workflowRepository !== source.repository) {
219
+ throw new Error(
220
+ "npm SLSA workflow repository disagrees with manual pickup source",
221
+ );
222
+ }
223
+ if (!source.workflowPaths.includes(workflow.path)) {
224
+ throw new Error(
225
+ "npm SLSA workflow path is not admitted by the manual pickup source",
226
+ );
227
+ }
228
+ if (!source.workflowRefs.includes(workflow.ref)) {
229
+ throw new Error(
230
+ "npm SLSA workflow ref is not admitted by the manual pickup source",
231
+ );
232
+ }
233
+ const dependency = (
234
+ statement.predicate?.buildDefinition?.resolvedDependencies || []
235
+ ).find(
236
+ (entry) =>
237
+ repositoryFromUrl(String(entry.uri || "").split("@")[0]) ===
238
+ source.repository,
239
+ );
240
+ const sourceSha = assertCommitSha(
241
+ dependency?.digest?.gitCommit,
242
+ "npm SLSA source commit",
243
+ );
244
+ const runUrl = assertString(
245
+ statement.predicate?.runDetails?.metadata?.invocationId,
246
+ "npm SLSA invocation URL",
247
+ );
248
+ return normalizeUpstreamRelease({
249
+ repository: source.repository,
250
+ channel: normalizedChannel,
251
+ tag: "",
252
+ tagTargetSha: "",
253
+ sourceSha,
254
+ package: {
255
+ name: source.package,
256
+ version,
257
+ integrity,
258
+ gitHead: optionalString(metadata.gitHead),
259
+ },
260
+ registryProvenance: {
261
+ registry: "https://registry.npmjs.org",
262
+ attestationUrl,
263
+ predicateType: slsa.predicateType,
264
+ subjectSha512: subject.digest.sha512,
265
+ repository: source.repository,
266
+ sourceSha,
267
+ workflowPath: workflow.path,
268
+ workflowRef: workflow.ref,
269
+ runUrl,
270
+ },
271
+ });
272
+ }
273
+
274
+ function pickupGraph(config, source) {
275
+ return {
276
+ schemaVersion: 1,
277
+ contract: "kungfu-buildchain-release-propagation-graph",
278
+ nodes: [
279
+ { id: source.id, repository: source.repository, package: source.package },
280
+ {
281
+ id: config.downstream.id,
282
+ repository: config.downstream.repository,
283
+ baseRef: config.downstream.baseRef,
284
+ lockPath: source.lockPath,
285
+ executionProfile: config.downstream.executionProfile,
286
+ },
287
+ ],
288
+ edges: [
289
+ {
290
+ id: `${source.id}-manual-pickup`,
291
+ from: source.id,
292
+ to: config.downstream.id,
293
+ channels: ["alpha", "release"],
294
+ channelPolicy: "preserve",
295
+ lockPath: source.lockPath,
296
+ prBaseRef: config.downstream.baseRef,
297
+ },
298
+ ],
299
+ };
300
+ }
301
+
302
+ export function createManualUpstreamPickupPlan({
303
+ config: configInput,
304
+ sourceId,
305
+ channel,
306
+ currentVersion,
307
+ upstreamRelease,
308
+ }) {
309
+ const config = normalizeManualUpstreamPickupConfig(configInput);
310
+ const source = config.sources.find((entry) => entry.id === sourceId);
311
+ if (!source)
312
+ throw new Error(`manual pickup source is not configured: ${sourceId}`);
313
+ const release = normalizeUpstreamRelease(upstreamRelease);
314
+ const normalizedChannel = normalizeChannel(channel, "manual pickup channel");
315
+ if (
316
+ release.repository !== source.repository ||
317
+ release.package?.name !== source.package
318
+ ) {
319
+ throw new Error("manual pickup release disagrees with the selected source");
320
+ }
321
+ if (release.channel !== normalizedChannel)
322
+ throw new Error("manual pickup release channel disagrees with the request");
323
+ const installedVersion = assertString(
324
+ currentVersion,
325
+ "manual pickup currentVersion",
326
+ );
327
+ const status =
328
+ installedVersion === release.package.version
329
+ ? "current"
330
+ : "update-available";
331
+ const propagationPlan =
332
+ status === "current"
333
+ ? null
334
+ : planReleasePropagation({
335
+ graph: pickupGraph(config, source),
336
+ upstreamRelease: release,
337
+ sourceNode: source.id,
338
+ });
339
+ return {
340
+ schemaVersion: 1,
341
+ contract: MANUAL_UPSTREAM_PICKUP_PLAN_CONTRACT,
342
+ invocation: "downstream-manual",
343
+ automaticTrigger: false,
344
+ runtime: config.runtime,
345
+ source: {
346
+ id: source.id,
347
+ repository: source.repository,
348
+ package: source.package,
349
+ channel: normalizedChannel,
350
+ distTag: source.distTags[normalizedChannel],
351
+ },
352
+ currentVersion: installedVersion,
353
+ resolvedVersion: release.package.version,
354
+ status,
355
+ upstreamRelease: release,
356
+ propagationPlan,
357
+ };
358
+ }
359
+
360
+ export function createManualUpstreamPickupCapture({
361
+ plan,
362
+ expectedDownstreamBaseSha,
363
+ }) {
364
+ const pickup = assertPlainObject(plan, "manual pickup plan");
365
+ if (
366
+ pickup.contract !== MANUAL_UPSTREAM_PICKUP_PLAN_CONTRACT ||
367
+ pickup.schemaVersion !== 1
368
+ ) {
369
+ throw new Error("manual pickup plan must use the v1 contract");
370
+ }
371
+ if (pickup.status === "current") {
372
+ return { plan: pickup, work: null, nextAction: "none" };
373
+ }
374
+ if (pickup.status !== "update-available" || !pickup.propagationPlan) {
375
+ throw new Error("manual pickup plan has an invalid status");
376
+ }
377
+ const expectedBaseSha = assertCommitSha(
378
+ expectedDownstreamBaseSha,
379
+ "expectedDownstreamBaseSha",
380
+ );
381
+ const work = createReleasePropagationWork({
382
+ plan: pickup.propagationPlan,
383
+ expectedDownstreamBaseSha: expectedBaseSha,
384
+ });
385
+ return { plan: pickup, work, nextAction: "claim" };
386
+ }
@@ -31,7 +31,9 @@ function normalizePackageFact(pkg = {}) {
31
31
  name: assertString(pkg.name, "upstreamRelease.package.name"),
32
32
  version: assertString(pkg.version, "upstreamRelease.package.version"),
33
33
  integrity: assertString(pkg.integrity, "upstreamRelease.package.integrity"),
34
- gitHead: assertCommitSha(pkg.gitHead || pkg.git_head, "upstreamRelease.package.gitHead"),
34
+ gitHead: pkg.gitHead || pkg.git_head
35
+ ? assertCommitSha(pkg.gitHead || pkg.git_head, "upstreamRelease.package.gitHead")
36
+ : "",
35
37
  };
36
38
  if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(normalized.integrity)) {
37
39
  throw new Error("upstreamRelease.package.integrity must be an exact sha512 SRI value");
@@ -39,6 +41,57 @@ function normalizePackageFact(pkg = {}) {
39
41
  return normalized;
40
42
  }
41
43
 
44
+ function normalizeRegistryProvenance(value, packageFact, repository, sourceSha) {
45
+ if (!value) return undefined;
46
+ if (!packageFact) throw new Error("upstreamRelease.registryProvenance requires package");
47
+ const input = assertPlainObject(value, "upstreamRelease.registryProvenance");
48
+ const normalized = {
49
+ registry: assertString(input.registry, "upstreamRelease.registryProvenance.registry"),
50
+ attestationUrl: assertString(input.attestationUrl || input.attestation_url, "upstreamRelease.registryProvenance.attestationUrl"),
51
+ predicateType: assertString(input.predicateType || input.predicate_type, "upstreamRelease.registryProvenance.predicateType"),
52
+ subjectSha512: assertString(input.subjectSha512 || input.subject_sha512, "upstreamRelease.registryProvenance.subjectSha512"),
53
+ repository: assertString(input.repository, "upstreamRelease.registryProvenance.repository"),
54
+ sourceSha: assertCommitSha(input.sourceSha || input.source_sha, "upstreamRelease.registryProvenance.sourceSha"),
55
+ workflowPath: assertString(input.workflowPath || input.workflow_path, "upstreamRelease.registryProvenance.workflowPath"),
56
+ workflowRef: assertString(input.workflowRef || input.workflow_ref, "upstreamRelease.registryProvenance.workflowRef"),
57
+ runUrl: assertString(input.runUrl || input.run_url, "upstreamRelease.registryProvenance.runUrl"),
58
+ };
59
+ if (normalized.registry !== "https://registry.npmjs.org") throw new Error("upstreamRelease.registryProvenance.registry must be the public npm registry");
60
+ if (!normalized.attestationUrl.startsWith("https://registry.npmjs.org/-/npm/v1/attestations/")) throw new Error("upstreamRelease.registryProvenance.attestationUrl must use the npm attestation API");
61
+ if (normalized.predicateType !== "https://slsa.dev/provenance/v1") throw new Error("upstreamRelease.registryProvenance must use SLSA provenance v1");
62
+ if (!/^[0-9a-f]{128}$/.test(normalized.subjectSha512)) throw new Error("upstreamRelease.registryProvenance.subjectSha512 must be an exact SHA-512 digest");
63
+ const sriDigest = Buffer.from(packageFact.integrity.slice("sha512-".length), "base64").toString("hex");
64
+ if (normalized.subjectSha512 !== sriDigest) throw new Error("upstreamRelease registry provenance subject must match package integrity");
65
+ if (normalized.repository !== repository || normalized.sourceSha !== sourceSha) throw new Error("upstreamRelease registry provenance source must match the release envelope");
66
+ return normalized;
67
+ }
68
+
69
+ function normalizeReleaseEvidence(release, packageFact, repository, sourceSha, version) {
70
+ const registryProvenance = normalizeRegistryProvenance(
71
+ release.registryProvenance || release.registry_provenance,
72
+ packageFact,
73
+ repository,
74
+ sourceSha,
75
+ );
76
+ const tag = optionalString(release.tag);
77
+ const target = release.tagTargetSha || release.tag_target_sha;
78
+ const tagTargetSha = target ? assertCommitSha(target, "upstreamRelease.tagTargetSha") : "";
79
+ if (!registryProvenance) {
80
+ if (tag !== `v${version}`) throw new Error("upstreamRelease.tag must exactly match the published version");
81
+ if (tagTargetSha !== sourceSha) throw new Error("upstreamRelease tag target must match sourceSha");
82
+ if (packageFact && packageFact.gitHead !== sourceSha) throw new Error("upstreamRelease package gitHead must match sourceSha");
83
+ } else if ((tag && tag !== `v${version}`) || (tagTargetSha && tagTargetSha !== sourceSha)) {
84
+ throw new Error("upstreamRelease optional tag evidence must match the provenance source");
85
+ }
86
+ const passportInput = release.releasePassport || release.release_passport;
87
+ return {
88
+ tag,
89
+ tagTargetSha,
90
+ registryProvenance,
91
+ releasePassport: passportInput ? normalizeReleasePassport(passportInput) : undefined,
92
+ };
93
+ }
94
+
42
95
  function normalizeDigestUrlFact(value = {}, label) {
43
96
  if (!value || typeof value !== "object" || Array.isArray(value)) {
44
97
  throw new Error(`${label} must be an object`);
@@ -117,33 +170,22 @@ export function normalizeUpstreamRelease(input) {
117
170
  throw new Error("upstreamRelease.repository must be owner/repo");
118
171
  }
119
172
  const sourceSha = assertCommitSha(release.sourceSha || release.source_sha, "upstreamRelease.sourceSha");
120
- const tagTargetSha = assertCommitSha(
121
- release.tagTargetSha || release.tag_target_sha,
122
- "upstreamRelease.tagTargetSha",
123
- );
124
173
  const version = packageFact?.version || publicationArtifact?.version || "";
125
- const tag = assertString(release.tag, "upstreamRelease.tag");
126
- if (tag !== `v${version}`) {
127
- throw new Error("upstreamRelease.tag must exactly match the published version");
128
- }
129
- if (tagTargetSha !== sourceSha) {
130
- throw new Error("upstreamRelease tag target must match sourceSha");
131
- }
132
- if (packageFact && packageFact.gitHead !== sourceSha) {
133
- throw new Error("upstreamRelease package gitHead must match sourceSha");
134
- }
174
+ const evidence = normalizeReleaseEvidence(release, packageFact, repository, sourceSha, version);
175
+ if (!evidence.registryProvenance && !evidence.releasePassport) normalizeReleasePassport();
135
176
  if (packageFact && publicationArtifact && packageFact.version !== publicationArtifact.version) {
136
177
  throw new Error("upstreamRelease package and publication artifact versions must match");
137
178
  }
138
179
  return {
139
180
  repository,
140
181
  channel: normalizeChannel(release.channel, "upstreamRelease.channel"),
141
- tag,
142
- tagTargetSha,
182
+ tag: evidence.tag,
183
+ tagTargetSha: evidence.tagTargetSha,
143
184
  sourceSha,
144
185
  package: packageFact,
145
186
  publicationArtifact,
146
- releasePassport: normalizeReleasePassport(release.releasePassport || release.release_passport),
187
+ releasePassport: evidence.releasePassport,
188
+ registryProvenance: evidence.registryProvenance,
147
189
  siteBundle: release.siteBundle || release.site_bundle
148
190
  ? {
149
191
  manifestSha256: assertString(
@@ -166,6 +208,7 @@ export function verifyReleaseLockBinding({ release, lock, downstream, propagatio
166
208
  package: lock.upstream?.package,
167
209
  publicationArtifact: lock.upstream?.publicationArtifact,
168
210
  releasePassport: lock.upstream?.releasePassport,
211
+ registryProvenance: lock.upstream?.registryProvenance,
169
212
  siteBundle: lock.upstream?.siteBundle,
170
213
  });
171
214
  if (JSON.stringify(lockRelease) !== JSON.stringify(normalizedRelease)) {
@@ -219,6 +219,7 @@ export function createReleasePropagationLock({
219
219
  package: upstreamRelease.package,
220
220
  publicationArtifact: upstreamRelease.publicationArtifact,
221
221
  releasePassport: upstreamRelease.releasePassport,
222
+ registryProvenance: upstreamRelease.registryProvenance,
222
223
  siteBundle: upstreamRelease.siteBundle,
223
224
  },
224
225
  downstream: {
@@ -406,7 +407,7 @@ export function createReleasePropagationReceipt({
406
407
  name: upstreamRelease.package.name,
407
408
  version: upstreamRelease.package.version,
408
409
  integrity: upstreamRelease.package.integrity,
409
- gitHead: upstreamRelease.package.gitHead,
410
+ gitHead: upstreamRelease.package.gitHead || null,
410
411
  }
411
412
  : null,
412
413
  },
@@ -414,7 +415,8 @@ export function createReleasePropagationReceipt({
414
415
  state: release.channel === "alpha" ? "complete" : "not-applicable",
415
416
  evidence: release.channel === "alpha"
416
417
  ? {
417
- releasePassportSha256: upstreamRelease.releasePassport.sha256,
418
+ releasePassportSha256: upstreamRelease.releasePassport?.sha256 || null,
419
+ registryProvenance: upstreamRelease.registryProvenance || null,
418
420
  tag: upstreamRelease.tag,
419
421
  }
420
422
  : null,
@@ -476,3 +478,11 @@ export {
476
478
  createPackageReleasePropagationCapture,
477
479
  normalizePackageReleasePropagationConfig,
478
480
  } from "./release-propagation-capture.js";
481
+ export {
482
+ MANUAL_UPSTREAM_PICKUP_CONFIG_CONTRACT,
483
+ MANUAL_UPSTREAM_PICKUP_PLAN_CONTRACT,
484
+ createManualUpstreamPickupCapture,
485
+ createManualUpstreamPickupPlan,
486
+ normalizeManualUpstreamPickupConfig,
487
+ resolveNpmRegistryRelease,
488
+ } from "./release-propagation-pickup.js";