@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.
- package/README.md +31 -0
- package/bin/buildchain.mjs +1 -1
- package/contracts/auditable-demo-media-profiles-v1.json +61 -0
- package/contracts/auditable-demo-scenario-v1.schema.json +153 -0
- package/contracts/evidence/auditable-demo-responsive-web-delivery-v1.json +3 -3
- package/contracts/evidence/auditable-demo-web-delivery-v1.json +3 -3
- package/dist/site/buildchain-contract.json +57 -6
- package/dist/site/buildchain-site.json +38 -22
- package/dist/site/capability-registry.json +1 -1
- package/dist/site/cli-registry.json +6 -2
- package/dist/site/kfd-claims.json +49 -9
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +3 -3
- package/dist/site/node-api-registry.json +203 -21
- package/dist/site/page-registry.json +28 -8
- package/dist/site/public-surface-audit.json +94 -6
- package/dist/site/publication-authority-registry.json +42 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +7 -7
- package/dist/site/workflow-registry.json +63 -0
- package/docs/auditable-demo.md +74 -7
- package/docs/cli-reference.md +24 -2
- package/docs/node-api-reference.md +63 -45
- package/package.json +1 -1
- package/packages/core/buildchain-contract.js +36 -1
- package/packages/core/buildchain-publication-authority.js +2 -0
- package/packages/core/release-propagation-pickup.js +386 -0
- package/packages/core/release-propagation-release.js +61 -18
- package/packages/core/release-propagation.js +12 -2
- package/scripts/auditable-demo-platform.mjs +506 -0
- package/scripts/auditable-demo-renditions.mjs +6 -1
- package/scripts/auditable-demo.mjs +14 -4
- package/scripts/build-standalone-binary.mjs +4 -0
- package/scripts/buildchain-cli-help.mjs +2 -1
- package/scripts/check-inventory.mjs +5 -0
- package/scripts/generate-site-bundle.mjs +2 -0
- 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.
|
|
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:
|
|
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
|
|
126
|
-
if (
|
|
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:
|
|
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
|
|
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";
|