@kungfu-tech/buildchain 4.1.3-alpha.2 → 4.1.3-alpha.3
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/action-taxonomy.json +8 -0
- package/architecture/agent-change-map.md +17 -0
- package/architecture/ci-lane-change-budget.json +85 -0
- package/architecture/decisions/0009-pipeline-version-preparation.md +186 -0
- package/architecture/internal-capabilities.json +32 -2
- package/architecture/maintainability-debt.json +3 -3
- package/architecture/maintainability-policy.json +7 -7
- package/architecture/minimal-consumer-migration.json +9 -0
- package/architecture/product-upstreams.json +20 -0
- package/architecture/release-topology.json +62 -25
- package/architecture/universal-workflow-bootstrap.json +2 -0
- package/architecture/universal-workflow-capability-policy.json +1 -1
- package/architecture/workflow-taxonomy.json +11 -0
- package/dist/readers/business-attempt.cjs +6 -2
- package/dist/site/buildchain-contract.json +4 -4
- package/dist/site/buildchain-site.json +9 -9
- package/dist/site/capability-registry.json +2 -2
- package/dist/site/kfd-claims.json +82 -8
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +1 -1
- package/dist/site/node-api-registry.json +18 -18
- package/dist/site/page-registry.json +4 -4
- package/dist/site/public-surface-audit.json +64 -7
- package/dist/site/publication-authority-registry.json +21 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +5 -5
- package/dist/site/workflow-registry.json +113 -7
- package/docs/node-api-reference.md +34 -34
- package/docs/workflow-catalog.md +1 -0
- package/package.json +1 -1
- package/packages/core/build/standalone/build.js +7 -1
- package/packages/core/consumer/buildchain-config.js +2 -21
- package/packages/core/consumer/contract/plan.js +48 -1
- package/packages/core/consumer/contract/products.js +20 -2
- package/packages/core/consumer/contract/reader.js +47 -0
- package/packages/core/dev-delivery/commands/project-cut-merge-queue-admission.mjs +47 -20
- package/packages/core/governance/buildchain-publication-authority.js +1 -0
- package/packages/core/providers/github/pipeline-entry-runs.js +203 -0
- package/packages/core/providers/github/pipeline-release-evidence.js +111 -0
- package/packages/core/providers/github/pipeline-stable-source.js +354 -0
- package/packages/core/providers/github/pipeline-version-artifacts.js +91 -0
- package/packages/core/providers/github/pipeline-version-readback.js +117 -0
- package/packages/core/providers/github/pipeline-version.js +10 -12
- package/packages/core/publication/pipeline/actions.js +17 -1
- package/packages/core/publication/pipeline/apply.js +23 -17
- package/packages/core/publication/pipeline/development-pr.js +28 -1
- package/packages/core/publication/pipeline/documents.js +37 -1
- package/packages/core/publication/pipeline/next-development.js +1 -0
- package/packages/core/publication/pipeline/pack.js +7 -1
- package/packages/core/publication/pipeline/plan.js +22 -0
- package/packages/core/publication/pipeline/prepare.js +44 -56
- package/packages/core/publication/pipeline/qualification.js +5 -0
- package/packages/core/publication/pipeline/stable-entry.js +215 -0
- package/packages/core/publication/pipeline/stable-products.js +317 -0
- package/packages/core/publication/pipeline/stable-wait.js +170 -0
- package/packages/core/publication/pipeline/stable.js +82 -0
- package/packages/core/publication/pipeline/version-actions.js +93 -0
- package/packages/core/publication/pipeline/version-build.js +130 -0
- package/packages/core/publication/pipeline/version-context.js +143 -0
- package/packages/core/publication/pipeline/version-material.js +36 -0
- package/packages/core/publication/pipeline/version-preparation.js +69 -0
- package/packages/core/publication/pipeline/version-regeneration.js +162 -0
- package/packages/core/publication/pipeline/version-source-guard.js +69 -0
- package/packages/core/publication/pipeline/worker.js +72 -0
- package/packages/core/release/stable-release-gate.js +11 -0
- package/packages/core/workflow/attempt/identity.js +7 -2
- package/packages/core/workflow/pipeline/actions.js +14 -1
- package/packages/core/workflow/pipeline/build.js +49 -2
- package/scripts/check-pipeline-publication-topology.mjs +12 -0
- package/scripts/check-release-topology.mjs +1 -0
- package/scripts/generate-site-bundle.mjs +1 -1
- package/scripts/verify-standalone-product.mjs +42 -0
|
@@ -61,19 +61,32 @@ function git(
|
|
|
61
61
|
{
|
|
62
62
|
env = {},
|
|
63
63
|
input = undefined,
|
|
64
|
+
inputFile = undefined,
|
|
65
|
+
outputFile = undefined,
|
|
64
66
|
encoding = "utf8",
|
|
65
67
|
reason = "git-failed",
|
|
66
68
|
} = {},
|
|
67
69
|
) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
let result, inputDescriptor, outputDescriptor;
|
|
71
|
+
try {
|
|
72
|
+
if (inputFile) inputDescriptor = fs.openSync(inputFile, "r");
|
|
73
|
+
if (outputFile) outputDescriptor = fs.openSync(outputFile, "w");
|
|
74
|
+
result = spawnSync("git", args, {
|
|
75
|
+
cwd,
|
|
76
|
+
env: { ...process.env, ...env },
|
|
77
|
+
input,
|
|
78
|
+
encoding,
|
|
79
|
+
stdio: [inputDescriptor ?? "pipe", outputDescriptor ?? "pipe", "pipe"],
|
|
80
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
81
|
+
});
|
|
82
|
+
} finally {
|
|
83
|
+
for (const descriptor of [inputDescriptor, outputDescriptor])
|
|
84
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
85
|
+
}
|
|
75
86
|
if (result.status !== 0) {
|
|
76
|
-
const detail = String(
|
|
87
|
+
const detail = String(
|
|
88
|
+
result.stderr || result.error?.message || result.stdout || "",
|
|
89
|
+
).trim();
|
|
77
90
|
throw new ProjectCutAdmissionError(
|
|
78
91
|
reason,
|
|
79
92
|
`git ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`,
|
|
@@ -135,7 +148,7 @@ function linearSourceCommits(cwd, fork, head) {
|
|
|
135
148
|
|
|
136
149
|
function patchId(cwd, patch) {
|
|
137
150
|
const output = git(cwd, ["patch-id", "--stable"], {
|
|
138
|
-
|
|
151
|
+
inputFile: patch,
|
|
139
152
|
encoding: "utf8",
|
|
140
153
|
reason: "patch-id-failed",
|
|
141
154
|
});
|
|
@@ -184,30 +197,41 @@ function replayProjectCut(cwd, base, head) {
|
|
|
184
197
|
env: indexEnvironment,
|
|
185
198
|
reason: "base-tree-unreadable",
|
|
186
199
|
});
|
|
187
|
-
|
|
200
|
+
// Generated bundles can exceed the metadata-output bound. Git streams the
|
|
201
|
+
// exact patches through private files; patch identity and replay stay intact.
|
|
202
|
+
const sourcePatch = path.join(temporary, "source.patch");
|
|
203
|
+
git(
|
|
188
204
|
cwd,
|
|
189
205
|
["diff", "--binary", "--full-index", "--no-ext-diff", fork, head],
|
|
190
206
|
{
|
|
191
|
-
|
|
207
|
+
outputFile: sourcePatch,
|
|
192
208
|
reason: "source-patch-unreadable",
|
|
193
209
|
},
|
|
194
210
|
);
|
|
195
|
-
const emptySourcePatch = sourcePatch.
|
|
211
|
+
const emptySourcePatch = fs.statSync(sourcePatch).size === 0;
|
|
196
212
|
if (emptySourcePatch && commits.length !== 1)
|
|
197
|
-
throw new ProjectCutAdmissionError(
|
|
198
|
-
|
|
199
|
-
|
|
213
|
+
throw new ProjectCutAdmissionError(
|
|
214
|
+
"empty-source-composition",
|
|
215
|
+
"an empty source composition must be exactly one linear commit",
|
|
216
|
+
);
|
|
217
|
+
if (!emptySourcePatch)
|
|
218
|
+
git(cwd, ["apply", "--cached", "--3way", "--whitespace=nowarn", "-"], {
|
|
219
|
+
env: indexEnvironment,
|
|
220
|
+
inputFile: sourcePatch,
|
|
221
|
+
encoding: "utf8",
|
|
222
|
+
reason: "project-cut-conflict",
|
|
200
223
|
});
|
|
201
224
|
const candidateTreeOid = git(cwd, ["write-tree"], {
|
|
202
225
|
env: indexEnvironment,
|
|
203
226
|
reason: "candidate-tree-unreadable",
|
|
204
227
|
});
|
|
205
|
-
const replayPatch =
|
|
228
|
+
const replayPatch = path.join(temporary, "replay.patch");
|
|
229
|
+
git(
|
|
206
230
|
cwd,
|
|
207
231
|
["diff", "--cached", "--binary", "--full-index", "--no-ext-diff", base],
|
|
208
232
|
{
|
|
209
233
|
env: indexEnvironment,
|
|
210
|
-
|
|
234
|
+
outputFile: replayPatch,
|
|
211
235
|
reason: "replay-patch-unreadable",
|
|
212
236
|
},
|
|
213
237
|
);
|
|
@@ -226,9 +250,12 @@ function replayProjectCut(cwd, base, head) {
|
|
|
226
250
|
reason: "replay-paths-unreadable",
|
|
227
251
|
},
|
|
228
252
|
);
|
|
229
|
-
const compositionDrifted = emptySourcePatch
|
|
230
|
-
|
|
231
|
-
|
|
253
|
+
const compositionDrifted = emptySourcePatch
|
|
254
|
+
? fs.statSync(replayPatch).size !== 0 ||
|
|
255
|
+
sourceNames !== "" ||
|
|
256
|
+
replayNames !== ""
|
|
257
|
+
: patchId(cwd, sourcePatch) !== patchId(cwd, replayPatch) ||
|
|
258
|
+
sourceNames !== replayNames;
|
|
232
259
|
if (compositionDrifted) {
|
|
233
260
|
throw new ProjectCutAdmissionError(
|
|
234
261
|
"composition-drift",
|
|
@@ -43,6 +43,7 @@ const DESCRIPTORS = Object.freeze([
|
|
|
43
43
|
[".github/workflows/public-ops-recover.yml", "product-publication", true, ["npm-publish", "github-release", "channel-ref"], "trusted-publishing", "none", "fixed", "fixed", ".github/workflows/.release-pipeline-products.yml"],
|
|
44
44
|
[".github/workflows/.ops-pipeline-execute.yml", "product-publication", true, ["npm-publish", "github-release", "channel-ref"], "trusted-publishing", "none", "fixed", "fixed", ".github/workflows/.release-pipeline-products.yml"],
|
|
45
45
|
[".github/workflows/.release-pipeline-products.yml", "product-publication", true, ["npm-publish", "github-release", "channel-ref"], "trusted-publishing", "none", "fixed", "fixed"],
|
|
46
|
+
[".github/workflows/.release-pipeline-version.yml", "governance-write"],
|
|
46
47
|
[".github/workflows/.ops-pipeline-delivery.yml", "governance-write"],
|
|
47
48
|
[".github/workflows/self-ops-housekeeping-daily.yml", "governance-write"],
|
|
48
49
|
[".github/workflows/self-ops-housekeeping-monthly.yml", "governance-write"],
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { runtimeSelector } from "../../runtime/entry/selection.js";
|
|
3
|
+
import { pipelineRunEntry, readPipelineCaller } from "./pipeline-run-entry.js";
|
|
4
|
+
import { recordDigest } from "../../release/discussion/envelope.js";
|
|
5
|
+
|
|
6
|
+
export async function pipelineEntryRuns(host, publishedAt) {
|
|
7
|
+
const created = encodeURIComponent(
|
|
8
|
+
`>=${new Date(publishedAt).toISOString()}`,
|
|
9
|
+
);
|
|
10
|
+
const prefix = `/repos/${host.repository}/actions/workflows/buildchain.yml/runs`;
|
|
11
|
+
const runs = [],
|
|
12
|
+
ids = new Set();
|
|
13
|
+
for (let page = 1; page <= 10; page++) {
|
|
14
|
+
const result = await host.request(
|
|
15
|
+
`${prefix}?event=pull_request&status=completed&created=${created}&per_page=100&page=${page}`,
|
|
16
|
+
{ allow404: true },
|
|
17
|
+
);
|
|
18
|
+
if (!result && page === 1) return [];
|
|
19
|
+
if (
|
|
20
|
+
!Array.isArray(result?.workflow_runs) ||
|
|
21
|
+
result.workflow_runs.length > 100
|
|
22
|
+
)
|
|
23
|
+
throw new Error("Published entry run inventory is incomplete");
|
|
24
|
+
for (const run of result.workflow_runs) {
|
|
25
|
+
if (
|
|
26
|
+
!Number.isSafeInteger(run.id) ||
|
|
27
|
+
run.id < 1 ||
|
|
28
|
+
ids.has(run.id) ||
|
|
29
|
+
!Number.isSafeInteger(run.run_attempt) ||
|
|
30
|
+
run.run_attempt < 1 ||
|
|
31
|
+
!Number.isFinite(Date.parse(run.created_at))
|
|
32
|
+
)
|
|
33
|
+
throw new Error(
|
|
34
|
+
"Published entry run inventory has ambiguous coordinates",
|
|
35
|
+
);
|
|
36
|
+
ids.add(run.id);
|
|
37
|
+
runs.push(run);
|
|
38
|
+
}
|
|
39
|
+
if (result.workflow_runs.length < 100)
|
|
40
|
+
return runs.sort(
|
|
41
|
+
(a, b) =>
|
|
42
|
+
Date.parse(b.created_at) - Date.parse(a.created_at) || b.id - a.id,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
throw new Error(
|
|
46
|
+
"Published entry run inventory exceeds its complete-read bound",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function sourceLock(host, source, channel) {
|
|
51
|
+
const pathname =
|
|
52
|
+
channel === "v4-alpha"
|
|
53
|
+
? ".buildchain/alpha-contract-lock.json"
|
|
54
|
+
: ".buildchain/contract-lock.json";
|
|
55
|
+
const file = await host.request(
|
|
56
|
+
`/repos/${host.repository}/contents/${pathname}?ref=${source.commit}`,
|
|
57
|
+
{ allow404: true },
|
|
58
|
+
);
|
|
59
|
+
if (!file) return { value: undefined, evidence: null };
|
|
60
|
+
if (
|
|
61
|
+
file.type !== "file" ||
|
|
62
|
+
file.encoding !== "base64" ||
|
|
63
|
+
!Number.isSafeInteger(file.size) ||
|
|
64
|
+
file.size < 1 ||
|
|
65
|
+
file.size > 1024 * 1024 ||
|
|
66
|
+
typeof file.content !== "string" ||
|
|
67
|
+
file.content.length > 2 * 1024 * 1024
|
|
68
|
+
)
|
|
69
|
+
throw new Error("Published entry runtime lock exceeds its source boundary");
|
|
70
|
+
const bytes = Buffer.from(file.content, "base64");
|
|
71
|
+
const blob = createHash("sha1")
|
|
72
|
+
.update(`blob ${bytes.length}\0`)
|
|
73
|
+
.update(bytes)
|
|
74
|
+
.digest("hex");
|
|
75
|
+
if (bytes.length !== file.size || blob !== file.sha)
|
|
76
|
+
throw new Error("Published entry runtime lock bytes changed");
|
|
77
|
+
const value = JSON.parse(
|
|
78
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
79
|
+
);
|
|
80
|
+
if (
|
|
81
|
+
value.schema === "buildchain.consumer-contract-lock/v2" &&
|
|
82
|
+
value.configDigest !== source.configDigest
|
|
83
|
+
)
|
|
84
|
+
throw new Error(
|
|
85
|
+
"Published entry runtime lock belongs to another consumer configuration",
|
|
86
|
+
);
|
|
87
|
+
return {
|
|
88
|
+
value,
|
|
89
|
+
evidence: {
|
|
90
|
+
path: pathname,
|
|
91
|
+
blob,
|
|
92
|
+
digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function pipelineEntryRun(run, plan, source, host) {
|
|
98
|
+
if (
|
|
99
|
+
run.event !== "pull_request" ||
|
|
100
|
+
run.status !== "completed" ||
|
|
101
|
+
run.repository?.full_name !== host.repository ||
|
|
102
|
+
run.head_repository?.full_name !== host.repository ||
|
|
103
|
+
run.path?.split("@")[0] !== ".github/workflows/buildchain.yml" ||
|
|
104
|
+
!Number.isFinite(Date.parse(run.created_at)) ||
|
|
105
|
+
Date.parse(run.created_at) < Date.parse(source.candidate.publishedAt)
|
|
106
|
+
)
|
|
107
|
+
return null;
|
|
108
|
+
const entry = pipelineRunEntry(run);
|
|
109
|
+
if (entry.recovery || entry.definition.sha !== source.candidate.sha)
|
|
110
|
+
return null;
|
|
111
|
+
const consumer = await host.source.source(
|
|
112
|
+
run.head_sha,
|
|
113
|
+
plan.source.configPath,
|
|
114
|
+
);
|
|
115
|
+
if (recordDigest(consumer.plan) !== plan.contractRoot) return null;
|
|
116
|
+
if (
|
|
117
|
+
consumer.identity.commit !== run.head_sha ||
|
|
118
|
+
consumer.identity.configPath !== plan.source.configPath ||
|
|
119
|
+
consumer.identity.repository !== host.repository
|
|
120
|
+
)
|
|
121
|
+
throw new Error("Published entry consumer source changed during readback");
|
|
122
|
+
await readPipelineCaller(
|
|
123
|
+
run,
|
|
124
|
+
consumer.identity.configPath,
|
|
125
|
+
host.request,
|
|
126
|
+
host.repository,
|
|
127
|
+
);
|
|
128
|
+
const lock = await sourceLock(host, consumer.identity, entry.channel);
|
|
129
|
+
const selection = runtimeSelector({
|
|
130
|
+
lock: lock.value,
|
|
131
|
+
workflowSha: entry.definition.sha,
|
|
132
|
+
});
|
|
133
|
+
if (selection.ref !== source.candidate.sha) return null;
|
|
134
|
+
return {
|
|
135
|
+
source: consumer.identity,
|
|
136
|
+
entry: entry.definition,
|
|
137
|
+
selection,
|
|
138
|
+
lock: lock.evidence,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function pipelineEntryCheck(run, host) {
|
|
143
|
+
const checks = [],
|
|
144
|
+
ids = new Set();
|
|
145
|
+
const prefix = `/repos/${host.repository}/commits/${run.head_sha}/check-runs`;
|
|
146
|
+
for (let page = 1; page <= 10; page++) {
|
|
147
|
+
const result = await host.request(
|
|
148
|
+
`${prefix}?check_name=check&filter=all&per_page=100&page=${page}`,
|
|
149
|
+
);
|
|
150
|
+
if (!Array.isArray(result?.check_runs) || result.check_runs.length > 100)
|
|
151
|
+
throw new Error("Published entry check inventory is incomplete");
|
|
152
|
+
for (const check of result.check_runs) {
|
|
153
|
+
if (!Number.isSafeInteger(check.id) || check.id < 1 || ids.has(check.id))
|
|
154
|
+
throw new Error(
|
|
155
|
+
"Published entry check inventory has ambiguous identities",
|
|
156
|
+
);
|
|
157
|
+
ids.add(check.id);
|
|
158
|
+
checks.push(check);
|
|
159
|
+
}
|
|
160
|
+
if (result.check_runs.length < 100)
|
|
161
|
+
return exactCheck(checks, run, host.repository);
|
|
162
|
+
}
|
|
163
|
+
throw new Error(
|
|
164
|
+
"Published entry check inventory exceeds its complete-read bound",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function exactCheck(checks, run, repository) {
|
|
169
|
+
const url = `https://github.com/${repository}/actions/runs/${run.id}/attempts/${run.run_attempt}`;
|
|
170
|
+
const matches = checks.filter(
|
|
171
|
+
(check) => check.name === "check" && check.details_url === url,
|
|
172
|
+
);
|
|
173
|
+
if (!matches.length) return null;
|
|
174
|
+
const values = matches.map((check) => {
|
|
175
|
+
const parsed =
|
|
176
|
+
/^buildchain:(attempt-[0-9a-f]{64}):([1-9][0-9]*):([1-9][0-9]*)$/u.exec(
|
|
177
|
+
check.external_id || "",
|
|
178
|
+
);
|
|
179
|
+
if (
|
|
180
|
+
!parsed ||
|
|
181
|
+
Number(parsed[2]) !== run.id ||
|
|
182
|
+
Number(parsed[3]) !== run.run_attempt ||
|
|
183
|
+
check.head_sha !== run.head_sha ||
|
|
184
|
+
check.status !== "completed" ||
|
|
185
|
+
!["success", "failure"].includes(check.conclusion) ||
|
|
186
|
+
check.app?.slug !== "github-actions"
|
|
187
|
+
)
|
|
188
|
+
throw new Error(
|
|
189
|
+
"Published entry check does not bind its exact native attempt",
|
|
190
|
+
);
|
|
191
|
+
return {
|
|
192
|
+
attempt: parsed[1],
|
|
193
|
+
outcome: check.conclusion,
|
|
194
|
+
summary: check.output?.summary || "",
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
if (values.some((value) => recordDigest(value) !== recordDigest(values[0])))
|
|
198
|
+
throw new Error("Published entry checks disagree about the same execution");
|
|
199
|
+
return {
|
|
200
|
+
...values[0],
|
|
201
|
+
ids: matches.map(({ id }) => id).sort((a, b) => a - b),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { recordDigest } from "../../release/discussion/envelope.js";
|
|
3
|
+
|
|
4
|
+
const NAMES = [
|
|
5
|
+
"plan",
|
|
6
|
+
"qualification",
|
|
7
|
+
"capsules",
|
|
8
|
+
"invocation",
|
|
9
|
+
"attestation",
|
|
10
|
+
"release",
|
|
11
|
+
];
|
|
12
|
+
const LIMIT = 8 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
async function inventory(request, prefix) {
|
|
15
|
+
const assets = [],
|
|
16
|
+
ids = new Set(),
|
|
17
|
+
names = new Set();
|
|
18
|
+
for (let page = 1; page <= 20; page++) {
|
|
19
|
+
const values = await request(`${prefix}/assets?per_page=100&page=${page}`);
|
|
20
|
+
if (!Array.isArray(values) || values.length > 100)
|
|
21
|
+
throw new Error("Published evidence asset inventory is incomplete");
|
|
22
|
+
for (const value of values) {
|
|
23
|
+
if (
|
|
24
|
+
!Number.isSafeInteger(value.id) ||
|
|
25
|
+
value.id < 1 ||
|
|
26
|
+
typeof value.name !== "string" ||
|
|
27
|
+
!value.name ||
|
|
28
|
+
ids.has(value.id) ||
|
|
29
|
+
names.has(value.name)
|
|
30
|
+
)
|
|
31
|
+
throw new Error("Published evidence asset inventory is ambiguous");
|
|
32
|
+
ids.add(value.id);
|
|
33
|
+
names.add(value.name);
|
|
34
|
+
assets.push(value);
|
|
35
|
+
}
|
|
36
|
+
if (values.length < 100) return assets;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(
|
|
39
|
+
"Published evidence inventory exceeds its complete-read bound",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function selectedAssets(assets) {
|
|
44
|
+
return NAMES.map((id) => {
|
|
45
|
+
const name = `buildchain.${id}.json`;
|
|
46
|
+
const asset = assets.find((value) => value.name === name);
|
|
47
|
+
if (!asset) return { name, missing: true };
|
|
48
|
+
if (
|
|
49
|
+
asset.state !== "uploaded" ||
|
|
50
|
+
!Number.isSafeInteger(asset.size) ||
|
|
51
|
+
asset.size < 1 ||
|
|
52
|
+
asset.size > LIMIT ||
|
|
53
|
+
!/^sha256:[0-9a-f]{64}$/u.test(asset.digest || "")
|
|
54
|
+
)
|
|
55
|
+
throw new Error("Published evidence asset lacks bounded immutable bytes");
|
|
56
|
+
return { id: asset.id, name, size: asset.size, digest: asset.digest };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Download only fixed evidence names from the exact already-observed release.
|
|
61
|
+
// There is no caller-supplied URL, artifact selector or provider write here.
|
|
62
|
+
export async function readPipelineReleaseEvidence(host, candidate) {
|
|
63
|
+
const repository = host.repository;
|
|
64
|
+
if (
|
|
65
|
+
!/^[\w.-]+\/[\w.-]+$/u.test(repository || "") ||
|
|
66
|
+
!Number.isSafeInteger(candidate.id) ||
|
|
67
|
+
candidate.id < 1
|
|
68
|
+
)
|
|
69
|
+
throw new Error("Published evidence requires exact release coordinates");
|
|
70
|
+
const prefix = `/repos/${repository}/releases/${candidate.id}`;
|
|
71
|
+
const selected = selectedAssets(await inventory(host.request, prefix));
|
|
72
|
+
const missing = selected
|
|
73
|
+
.filter((value) => value.missing)
|
|
74
|
+
.map(({ name }) => name);
|
|
75
|
+
if (missing.length) return { status: "missing", missing };
|
|
76
|
+
const [owner, repo] = repository.split("/");
|
|
77
|
+
const values = {};
|
|
78
|
+
let bundle;
|
|
79
|
+
for (const asset of selected) {
|
|
80
|
+
const result = await host.github.rest.repos.getReleaseAsset({
|
|
81
|
+
owner,
|
|
82
|
+
repo,
|
|
83
|
+
asset_id: asset.id,
|
|
84
|
+
headers: { accept: "application/octet-stream" },
|
|
85
|
+
});
|
|
86
|
+
const bytes = Buffer.from(result.data);
|
|
87
|
+
if (
|
|
88
|
+
bytes.length !== asset.size ||
|
|
89
|
+
bytes.length > LIMIT ||
|
|
90
|
+
`sha256:${createHash("sha256").update(bytes).digest("hex")}` !==
|
|
91
|
+
asset.digest
|
|
92
|
+
)
|
|
93
|
+
throw new Error(
|
|
94
|
+
"Published evidence download differs from provider bytes",
|
|
95
|
+
);
|
|
96
|
+
const id = asset.name.slice("buildchain.".length, -".json".length);
|
|
97
|
+
if (id === "attestation") bundle = bytes;
|
|
98
|
+
else
|
|
99
|
+
values[id] = JSON.parse(
|
|
100
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
recordDigest(selectedAssets(await inventory(host.request, prefix))) !==
|
|
105
|
+
recordDigest(selected)
|
|
106
|
+
)
|
|
107
|
+
throw new Error(
|
|
108
|
+
"Published evidence assets changed during provider readback",
|
|
109
|
+
);
|
|
110
|
+
return { status: "present", values, bundle, assets: selected };
|
|
111
|
+
}
|