@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
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { recordDigest } from "../../release/discussion/envelope.js";
|
|
3
|
+
import { verifyPipelinePublicationPlan } from "../../publication/pipeline/plan.js";
|
|
4
|
+
import { compareDevelopmentVersions } from "../../publication/publication-development.js";
|
|
5
|
+
import { githubPipelineSource } from "./pipeline-source.js";
|
|
6
|
+
import { githubPipelineVersion } from "./pipeline-version.js";
|
|
7
|
+
import { normalInputs } from "../../consumer/contract/entries.js";
|
|
8
|
+
|
|
9
|
+
const SHA = /^[0-9a-f]{40}$/u;
|
|
10
|
+
const STABLE = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
|
|
11
|
+
|
|
12
|
+
function publishedRelease(value, repository, tag, prerelease) {
|
|
13
|
+
if (
|
|
14
|
+
!Number.isSafeInteger(value?.id) ||
|
|
15
|
+
value.id < 1 ||
|
|
16
|
+
value.tag_name !== tag ||
|
|
17
|
+
value.draft !== false ||
|
|
18
|
+
value.prerelease !== prerelease ||
|
|
19
|
+
value.html_url !== `https://github.com/${repository}/releases/tag/${tag}` ||
|
|
20
|
+
typeof value.published_at !== "string" ||
|
|
21
|
+
!Number.isFinite(Date.parse(value.published_at))
|
|
22
|
+
)
|
|
23
|
+
throw new Error(
|
|
24
|
+
"Stable eligibility requires the exact public release identity",
|
|
25
|
+
);
|
|
26
|
+
return {
|
|
27
|
+
id: value.id,
|
|
28
|
+
tag,
|
|
29
|
+
publishedAt: new Date(value.published_at).toISOString(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function exactTag(get, tag) {
|
|
34
|
+
const pointer = await get(`/git/ref/tags/${encodeURIComponent(tag)}`);
|
|
35
|
+
if (
|
|
36
|
+
pointer.ref !== `refs/tags/${tag}` ||
|
|
37
|
+
!SHA.test(pointer.object?.sha || "")
|
|
38
|
+
)
|
|
39
|
+
throw new Error("Stable release tag identity changed");
|
|
40
|
+
let object = pointer.object;
|
|
41
|
+
for (let depth = 0; object.type === "tag" && depth < 4; depth++) {
|
|
42
|
+
const annotated = await get(`/git/tags/${object.sha}`);
|
|
43
|
+
if (annotated.sha !== object.sha || !SHA.test(annotated.object?.sha || ""))
|
|
44
|
+
throw new Error("Stable annotated tag identity changed");
|
|
45
|
+
object = annotated.object;
|
|
46
|
+
}
|
|
47
|
+
if (object.type !== "commit" || !SHA.test(object.sha))
|
|
48
|
+
throw new Error(
|
|
49
|
+
"Stable release tag must resolve to a bounded exact commit",
|
|
50
|
+
);
|
|
51
|
+
return { object: pointer.object.sha, commit: object.sha };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function releases(get) {
|
|
55
|
+
const result = [],
|
|
56
|
+
ids = new Set();
|
|
57
|
+
for (let page = 1; page <= 20; page++) {
|
|
58
|
+
const values = await get(`/releases?per_page=100&page=${page}`);
|
|
59
|
+
if (!Array.isArray(values) || values.length > 100)
|
|
60
|
+
throw new Error("Stable release inventory is incomplete");
|
|
61
|
+
for (const value of values) {
|
|
62
|
+
if (!Number.isSafeInteger(value.id) || value.id < 1 || ids.has(value.id))
|
|
63
|
+
throw new Error(
|
|
64
|
+
"Stable release inventory has duplicate or invalid identities",
|
|
65
|
+
);
|
|
66
|
+
ids.add(value.id);
|
|
67
|
+
result.push(value);
|
|
68
|
+
}
|
|
69
|
+
if (values.length < 100) return result;
|
|
70
|
+
}
|
|
71
|
+
throw new Error("Stable release inventory exceeds its complete-read bound");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function predecessors(values, plan) {
|
|
75
|
+
const older = [],
|
|
76
|
+
tags = new Set(),
|
|
77
|
+
major = plan.version.split(".")[0];
|
|
78
|
+
for (const value of values) {
|
|
79
|
+
const match = STABLE.exec(value.tag_name || "");
|
|
80
|
+
if (!match || match[1] !== major || value.draft === true) continue;
|
|
81
|
+
const release = publishedRelease(
|
|
82
|
+
value,
|
|
83
|
+
plan.source.repository,
|
|
84
|
+
value.tag_name,
|
|
85
|
+
false,
|
|
86
|
+
);
|
|
87
|
+
if (tags.has(release.tag))
|
|
88
|
+
throw new Error("Stable release inventory repeats an exact tag");
|
|
89
|
+
tags.add(release.tag);
|
|
90
|
+
const order = compareDevelopmentVersions(
|
|
91
|
+
release.tag.slice(1),
|
|
92
|
+
plan.version,
|
|
93
|
+
);
|
|
94
|
+
if (order > 0)
|
|
95
|
+
throw new Error("A newer stable product has already been published");
|
|
96
|
+
// The current version can already exist during recovery of the same
|
|
97
|
+
// transaction. The effect journal separately verifies its exact bytes.
|
|
98
|
+
if (order < 0) older.push(release);
|
|
99
|
+
}
|
|
100
|
+
older.sort((a, b) =>
|
|
101
|
+
compareDevelopmentVersions(b.tag.slice(1), a.tag.slice(1)),
|
|
102
|
+
);
|
|
103
|
+
const comparison = older[0] || null;
|
|
104
|
+
const latest =
|
|
105
|
+
[...older].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))[0] ||
|
|
106
|
+
null;
|
|
107
|
+
return { comparison, latest };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function treeFiles(get, commitSha) {
|
|
111
|
+
const commit = await get(`/git/commits/${commitSha}`);
|
|
112
|
+
if (commit.sha !== commitSha || !SHA.test(commit.tree?.sha || ""))
|
|
113
|
+
throw new Error("Stable source commit or tree identity changed");
|
|
114
|
+
const tree = await get(`/git/trees/${commit.tree.sha}?recursive=1`);
|
|
115
|
+
if (
|
|
116
|
+
tree.sha !== commit.tree.sha ||
|
|
117
|
+
tree.truncated !== false ||
|
|
118
|
+
!Array.isArray(tree.tree) ||
|
|
119
|
+
tree.tree.length > 100_000
|
|
120
|
+
)
|
|
121
|
+
throw new Error("Stable product difference requires a complete Git tree");
|
|
122
|
+
const files = new Map(),
|
|
123
|
+
paths = new Set();
|
|
124
|
+
for (const entry of tree.tree) {
|
|
125
|
+
if (
|
|
126
|
+
typeof entry.path !== "string" ||
|
|
127
|
+
!entry.path ||
|
|
128
|
+
entry.path
|
|
129
|
+
.split("/")
|
|
130
|
+
.some((part) => !part || part === "." || part === "..") ||
|
|
131
|
+
paths.has(entry.path) ||
|
|
132
|
+
!SHA.test(entry.sha || "")
|
|
133
|
+
)
|
|
134
|
+
throw new Error("Stable product tree has an ambiguous or invalid path");
|
|
135
|
+
paths.add(entry.path);
|
|
136
|
+
const modes = {
|
|
137
|
+
tree: ["040000"],
|
|
138
|
+
blob: ["100644", "100755", "120000"],
|
|
139
|
+
commit: ["160000"],
|
|
140
|
+
};
|
|
141
|
+
if (!modes[entry.type]?.includes(entry.mode))
|
|
142
|
+
throw new Error(
|
|
143
|
+
"Stable product tree has an unsupported entry type or mode",
|
|
144
|
+
);
|
|
145
|
+
if (entry.type !== "tree")
|
|
146
|
+
files.set(entry.path, {
|
|
147
|
+
sha: entry.sha,
|
|
148
|
+
mode: entry.mode,
|
|
149
|
+
type: entry.type,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return { tree: commit.tree.sha, files };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function impactDocument(get, files, pathname, version) {
|
|
156
|
+
const entry = files.get(pathname);
|
|
157
|
+
if (entry?.type !== "blob" || !["100644", "100755"].includes(entry.mode))
|
|
158
|
+
throw new Error(
|
|
159
|
+
"Stable impact must be a regular file in the exact Alpha tree",
|
|
160
|
+
);
|
|
161
|
+
const blob = await get(`/git/blobs/${entry.sha}`);
|
|
162
|
+
if (
|
|
163
|
+
blob.sha !== entry.sha ||
|
|
164
|
+
blob.encoding !== "base64" ||
|
|
165
|
+
!Number.isSafeInteger(blob.size) ||
|
|
166
|
+
blob.size < 1 ||
|
|
167
|
+
blob.size > 1024 * 1024 ||
|
|
168
|
+
typeof blob.content !== "string" ||
|
|
169
|
+
blob.content.length > 2 * 1024 * 1024
|
|
170
|
+
)
|
|
171
|
+
throw new Error("Stable impact exceeds its exact Git blob boundary");
|
|
172
|
+
const bytes = Buffer.from(blob.content, "base64");
|
|
173
|
+
const sha = createHash("sha1")
|
|
174
|
+
.update(`blob ${bytes.length}\0`)
|
|
175
|
+
.update(bytes)
|
|
176
|
+
.digest("hex");
|
|
177
|
+
if (bytes.length !== blob.size || sha !== entry.sha)
|
|
178
|
+
throw new Error("Stable impact Git blob bytes changed");
|
|
179
|
+
const value = JSON.parse(
|
|
180
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
|
181
|
+
);
|
|
182
|
+
if (value?.release?.version !== version)
|
|
183
|
+
throw new Error("Stable impact does not belong to the exact Alpha version");
|
|
184
|
+
return {
|
|
185
|
+
path: pathname,
|
|
186
|
+
blob: sha,
|
|
187
|
+
digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
|
|
188
|
+
value,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function changedPaths(before, after) {
|
|
193
|
+
return [...new Set([...before.keys(), ...after.keys()])]
|
|
194
|
+
.filter(
|
|
195
|
+
(pathname) =>
|
|
196
|
+
recordDigest(before.get(pathname) || null) !==
|
|
197
|
+
recordDigest(after.get(pathname) || null),
|
|
198
|
+
)
|
|
199
|
+
.sort();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stableSourceReader(plan, host) {
|
|
203
|
+
verifyPipelinePublicationPlan(plan);
|
|
204
|
+
const repository = plan.source.repository;
|
|
205
|
+
if (
|
|
206
|
+
host.repository !== repository ||
|
|
207
|
+
!/^[\w.-]+\/[\w.-]+$/u.test(repository) ||
|
|
208
|
+
plan.channel !== "stable" ||
|
|
209
|
+
!plan.stablePolicy ||
|
|
210
|
+
!/^\d+\.\d+\.\d+-alpha\.\d+$/u.test(plan.candidateVersion) ||
|
|
211
|
+
plan.candidateVersion.split("-alpha.")[0] !== plan.version
|
|
212
|
+
)
|
|
213
|
+
throw new Error(
|
|
214
|
+
"Stable source collection requires its exact repository and Alpha candidate plan",
|
|
215
|
+
);
|
|
216
|
+
const base = `/repos/${repository}`;
|
|
217
|
+
normalInputs({ "config-path": plan.source.configPath });
|
|
218
|
+
const request = (endpoint, options = {}) => {
|
|
219
|
+
if (
|
|
220
|
+
!endpoint.startsWith(`${base}/`) ||
|
|
221
|
+
(options.method && options.method !== "GET")
|
|
222
|
+
)
|
|
223
|
+
throw new Error(
|
|
224
|
+
"Stable source collection cannot mutate or cross repositories",
|
|
225
|
+
);
|
|
226
|
+
return host.request(endpoint, options);
|
|
227
|
+
};
|
|
228
|
+
const get = (pathname) => request(`${base}${pathname}`);
|
|
229
|
+
return { repository, request, get };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function recheckStableSources(
|
|
233
|
+
get,
|
|
234
|
+
{ plan, pointer, release, selected, prior, latest },
|
|
235
|
+
) {
|
|
236
|
+
const repository = plan.source.repository,
|
|
237
|
+
tag = `v${plan.candidateVersion}`;
|
|
238
|
+
const repeatedRelease = publishedRelease(
|
|
239
|
+
await get(`/releases/tags/${tag}`),
|
|
240
|
+
repository,
|
|
241
|
+
tag,
|
|
242
|
+
true,
|
|
243
|
+
);
|
|
244
|
+
if (
|
|
245
|
+
recordDigest(await exactTag(get, tag)) !== recordDigest(pointer) ||
|
|
246
|
+
recordDigest(repeatedRelease) !== recordDigest(release) ||
|
|
247
|
+
recordDigest(predecessors(await releases(get), plan)) !==
|
|
248
|
+
recordDigest(selected) ||
|
|
249
|
+
(prior &&
|
|
250
|
+
recordDigest(await exactTag(get, selected.comparison.tag)) !==
|
|
251
|
+
recordDigest(prior)) ||
|
|
252
|
+
(latest &&
|
|
253
|
+
recordDigest(await exactTag(get, selected.latest.tag)) !==
|
|
254
|
+
recordDigest(latest))
|
|
255
|
+
)
|
|
256
|
+
throw new Error("Stable source facts changed during provider readback");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Read-only provider facts. This result intentionally contains no successful
|
|
260
|
+
// canary or release authorization; those require independent qualification.
|
|
261
|
+
export async function readPipelineStableSource(plan, host) {
|
|
262
|
+
const { repository, request, get } = stableSourceReader(plan, host);
|
|
263
|
+
const tag = `v${plan.candidateVersion}`;
|
|
264
|
+
const pointer = await exactTag(get, tag);
|
|
265
|
+
if (pointer.commit !== plan.intentSource.commit)
|
|
266
|
+
throw new Error("Published Alpha differs from the exact channel PR source");
|
|
267
|
+
const release = publishedRelease(
|
|
268
|
+
await get(`/releases/tags/${tag}`),
|
|
269
|
+
repository,
|
|
270
|
+
tag,
|
|
271
|
+
true,
|
|
272
|
+
);
|
|
273
|
+
const candidate = await githubPipelineSource(request, repository).source(
|
|
274
|
+
pointer.commit,
|
|
275
|
+
plan.source.configPath,
|
|
276
|
+
);
|
|
277
|
+
if (
|
|
278
|
+
recordDigest(candidate.identity) !== recordDigest(plan.intentSource) ||
|
|
279
|
+
recordDigest(candidate.plan) !== plan.contractRoot ||
|
|
280
|
+
recordDigest(candidate.plan.stable) !== recordDigest(plan.stablePolicy) ||
|
|
281
|
+
recordDigest(candidate.plan.version) !== recordDigest(plan.versionPolicy)
|
|
282
|
+
)
|
|
283
|
+
throw new Error(
|
|
284
|
+
"Published Alpha source or product policy differs from the admitted plan",
|
|
285
|
+
);
|
|
286
|
+
const version = await githubPipelineVersion(request, repository).inspect(
|
|
287
|
+
candidate.identity,
|
|
288
|
+
candidate.plan.version,
|
|
289
|
+
);
|
|
290
|
+
if (version.version !== plan.candidateVersion)
|
|
291
|
+
throw new Error(
|
|
292
|
+
"Published Alpha version documents disagree with its exact tag",
|
|
293
|
+
);
|
|
294
|
+
const selected = predecessors(await releases(get), plan);
|
|
295
|
+
const prior = selected.comparison
|
|
296
|
+
? await exactTag(get, selected.comparison.tag)
|
|
297
|
+
: null;
|
|
298
|
+
if ((prior?.commit || null) !== plan.previousChannelCommit)
|
|
299
|
+
throw new Error(
|
|
300
|
+
"Stable comparison differs from the retained published channel",
|
|
301
|
+
);
|
|
302
|
+
const current = await treeFiles(get, pointer.commit);
|
|
303
|
+
if (current.tree !== candidate.identity.tree)
|
|
304
|
+
throw new Error(
|
|
305
|
+
"Alpha tree changed between independent source observations",
|
|
306
|
+
);
|
|
307
|
+
const config = current.files.get(candidate.identity.configPath);
|
|
308
|
+
if (
|
|
309
|
+
config?.type !== "blob" ||
|
|
310
|
+
!["100644", "100755"].includes(config.mode) ||
|
|
311
|
+
config.sha !== candidate.identity.configBlob
|
|
312
|
+
)
|
|
313
|
+
throw new Error(
|
|
314
|
+
"Alpha configuration is not the exact blob in its complete tree",
|
|
315
|
+
);
|
|
316
|
+
const previous = prior
|
|
317
|
+
? await treeFiles(get, prior.commit)
|
|
318
|
+
: { files: new Map() };
|
|
319
|
+
const impact = await impactDocument(
|
|
320
|
+
get,
|
|
321
|
+
current.files,
|
|
322
|
+
plan.stablePolicy.impact_file,
|
|
323
|
+
plan.candidateVersion,
|
|
324
|
+
);
|
|
325
|
+
const latest =
|
|
326
|
+
selected.latest &&
|
|
327
|
+
(selected.latest.tag === selected.comparison.tag
|
|
328
|
+
? prior
|
|
329
|
+
: await exactTag(get, selected.latest.tag));
|
|
330
|
+
await recheckStableSources(get, {
|
|
331
|
+
plan,
|
|
332
|
+
pointer,
|
|
333
|
+
release,
|
|
334
|
+
selected,
|
|
335
|
+
prior,
|
|
336
|
+
latest,
|
|
337
|
+
});
|
|
338
|
+
const body = {
|
|
339
|
+
schema: "buildchain.pipeline-stable-source/v1",
|
|
340
|
+
planRoot: plan.root,
|
|
341
|
+
source: candidate.identity,
|
|
342
|
+
contractRoot: plan.contractRoot,
|
|
343
|
+
candidate: { ...release, sha: pointer.commit, tree: current.tree },
|
|
344
|
+
previousStable: selected.latest
|
|
345
|
+
? { ...selected.latest, sha: latest.commit }
|
|
346
|
+
: null,
|
|
347
|
+
comparisonStable: selected.comparison
|
|
348
|
+
? { ...selected.comparison, sha: prior.commit, tree: previous.tree }
|
|
349
|
+
: null,
|
|
350
|
+
impact,
|
|
351
|
+
changedPaths: changedPaths(previous.files, current.files),
|
|
352
|
+
};
|
|
353
|
+
return { ...body, root: recordDigest(body) };
|
|
354
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import artifact from "@actions/artifact";
|
|
4
|
+
import { recordDigest } from "../../release/discussion/envelope.js";
|
|
5
|
+
import {
|
|
6
|
+
publicationPath,
|
|
7
|
+
writeImmutablePublicationFile,
|
|
8
|
+
} from "../../publication/pipeline/files.js";
|
|
9
|
+
import { pipelineVersionRegenerationResult } from "../../publication/pipeline/version-regeneration.js";
|
|
10
|
+
import {
|
|
11
|
+
pipelineVersionArtifactName,
|
|
12
|
+
readPipelineVersionBuild,
|
|
13
|
+
} from "./pipeline-version-readback.js";
|
|
14
|
+
|
|
15
|
+
function verifyResult(context, result, platform) {
|
|
16
|
+
const expected = pipelineVersionRegenerationResult(
|
|
17
|
+
context.preparation,
|
|
18
|
+
platform,
|
|
19
|
+
result.files,
|
|
20
|
+
);
|
|
21
|
+
if (recordDigest(result) !== recordDigest(expected))
|
|
22
|
+
throw new Error(
|
|
23
|
+
"Version artifact changed its exact preparation, platform or bytes",
|
|
24
|
+
);
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function githubPipelineVersionArtifacts(host, client = artifact) {
|
|
29
|
+
async function upload(context, result, directory) {
|
|
30
|
+
verifyResult(context, result, result.platform);
|
|
31
|
+
const file = writeImmutablePublicationFile(
|
|
32
|
+
path.join(directory, "version.json"),
|
|
33
|
+
JSON.stringify(result),
|
|
34
|
+
);
|
|
35
|
+
const uploaded = await client.uploadArtifact(
|
|
36
|
+
pipelineVersionArtifactName(context, result.platform),
|
|
37
|
+
[file],
|
|
38
|
+
directory,
|
|
39
|
+
{
|
|
40
|
+
retentionDays: 30,
|
|
41
|
+
compressionLevel: 0,
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
if (!uploaded.id || !uploaded.digest)
|
|
45
|
+
throw new Error("Version upload omitted immutable provider coordinates");
|
|
46
|
+
return uploaded;
|
|
47
|
+
}
|
|
48
|
+
async function download(context, directory) {
|
|
49
|
+
const { build, assets } = await readPipelineVersionBuild(context, host);
|
|
50
|
+
const [repositoryOwner, repositoryName] = host.repository.split("/");
|
|
51
|
+
const results = [];
|
|
52
|
+
for (const [index, asset] of assets.entries()) {
|
|
53
|
+
const target = path.join(directory, String(asset.id));
|
|
54
|
+
fs.mkdirSync(target, { recursive: true });
|
|
55
|
+
const downloaded = await client.downloadArtifact(asset.id, {
|
|
56
|
+
path: target,
|
|
57
|
+
expectedHash: asset.digest,
|
|
58
|
+
findBy: {
|
|
59
|
+
repositoryOwner,
|
|
60
|
+
repositoryName,
|
|
61
|
+
workflowRunId: context.runId,
|
|
62
|
+
token: host.token,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
if (downloaded.digestMismatch)
|
|
66
|
+
throw new Error(
|
|
67
|
+
"Version download digest differs from its provider archive",
|
|
68
|
+
);
|
|
69
|
+
if (
|
|
70
|
+
recordDigest(fs.readdirSync(target)) !== recordDigest(["version.json"])
|
|
71
|
+
)
|
|
72
|
+
throw new Error("Version artifact contains undeclared files");
|
|
73
|
+
const file = publicationPath(target, "version.json");
|
|
74
|
+
// JSON escaping can expand the bounded UTF-8 document inventory sixfold.
|
|
75
|
+
if (fs.statSync(file).size > 384 * 1024 * 1024)
|
|
76
|
+
throw new Error("Version artifact exceeds its encoded material limit");
|
|
77
|
+
const bytes = fs.readFileSync(file);
|
|
78
|
+
if (!Buffer.from(bytes.toString("utf8")).equals(bytes))
|
|
79
|
+
throw new Error("Version artifact is not canonical UTF-8 text");
|
|
80
|
+
results.push(
|
|
81
|
+
verifyResult(
|
|
82
|
+
context,
|
|
83
|
+
JSON.parse(bytes.toString("utf8")),
|
|
84
|
+
context.preparation.platforms[index],
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return { build, results };
|
|
89
|
+
}
|
|
90
|
+
return { upload, download };
|
|
91
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { recordDigest } from "../../release/discussion/envelope.js";
|
|
2
|
+
import { readPipelineCaller } from "./pipeline-run-entry.js";
|
|
3
|
+
|
|
4
|
+
export const pipelineVersionArtifactName = (context, platform) =>
|
|
5
|
+
`buildchain-version-${context.preparation.root.slice(7)}-${context.runId}-${context.runAttempt}-${platform}`;
|
|
6
|
+
|
|
7
|
+
export const pipelineVersionJobName = (preparation, platform) =>
|
|
8
|
+
`Prepare version (${preparation.purpose}, ${platform})`;
|
|
9
|
+
|
|
10
|
+
export async function readPipelineVersionBuild(context, host) {
|
|
11
|
+
const { preparation, runId, runAttempt, definitionSha } = context;
|
|
12
|
+
const { repository, request, runs } = host;
|
|
13
|
+
if (preparation.source.repository !== repository)
|
|
14
|
+
throw new Error("Version preparation crossed its repository boundary");
|
|
15
|
+
const { run, jobs } = await runs.read(runId, runAttempt);
|
|
16
|
+
await readPipelineCaller(
|
|
17
|
+
run,
|
|
18
|
+
preparation.source.configPath,
|
|
19
|
+
request,
|
|
20
|
+
repository,
|
|
21
|
+
);
|
|
22
|
+
const definitions = (run.referenced_workflows || []).filter((entry) =>
|
|
23
|
+
entry.path?.startsWith(
|
|
24
|
+
"kungfu-systems/buildchain/.github/workflows/.release-pipeline-products.yml@",
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
const versionDefinitions = (run.referenced_workflows || []).filter((entry) =>
|
|
28
|
+
entry.path?.startsWith(
|
|
29
|
+
"kungfu-systems/buildchain/.github/workflows/.release-pipeline-version.yml@",
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
if (
|
|
33
|
+
definitions.length !== 1 ||
|
|
34
|
+
definitions[0].sha !== definitionSha ||
|
|
35
|
+
versionDefinitions.length !== 1 ||
|
|
36
|
+
versionDefinitions[0].sha !== definitionSha ||
|
|
37
|
+
!/^[0-9a-f]{40}$/u.test(definitionSha || "")
|
|
38
|
+
)
|
|
39
|
+
throw new Error("Version preparation changed its exact hosted definition");
|
|
40
|
+
const selected = preparation.platforms.map((platform) => {
|
|
41
|
+
const name = pipelineVersionJobName(preparation, platform);
|
|
42
|
+
const matches = jobs.filter(
|
|
43
|
+
(job) => job.name === name || job.name.endsWith(` / ${name}`),
|
|
44
|
+
);
|
|
45
|
+
if (
|
|
46
|
+
matches.length !== 1 ||
|
|
47
|
+
matches[0].run_id !== runId ||
|
|
48
|
+
matches[0].run_attempt !== runAttempt ||
|
|
49
|
+
matches[0].status !== "completed" ||
|
|
50
|
+
matches[0].conclusion !== "success"
|
|
51
|
+
)
|
|
52
|
+
throw new Error(
|
|
53
|
+
"Version preparation requires every exact successful product job",
|
|
54
|
+
);
|
|
55
|
+
return matches[0];
|
|
56
|
+
});
|
|
57
|
+
if (
|
|
58
|
+
!selected.length ||
|
|
59
|
+
new Set(preparation.platforms).size !== selected.length
|
|
60
|
+
)
|
|
61
|
+
throw new Error(
|
|
62
|
+
"Version preparation platform inventory is empty or duplicated",
|
|
63
|
+
);
|
|
64
|
+
const inventory = await request(
|
|
65
|
+
`/repos/${repository}/actions/runs/${runId}/artifacts?per_page=100`,
|
|
66
|
+
);
|
|
67
|
+
if (
|
|
68
|
+
!Array.isArray(inventory.artifacts) ||
|
|
69
|
+
inventory.total_count !== inventory.artifacts.length ||
|
|
70
|
+
inventory.total_count > 100
|
|
71
|
+
)
|
|
72
|
+
throw new Error("Version preparation artifact inventory is incomplete");
|
|
73
|
+
const assets = preparation.platforms.map((platform) => {
|
|
74
|
+
const matches = inventory.artifacts.filter(
|
|
75
|
+
(asset) => asset.name === pipelineVersionArtifactName(context, platform),
|
|
76
|
+
);
|
|
77
|
+
if (
|
|
78
|
+
matches.length !== 1 ||
|
|
79
|
+
!Number.isSafeInteger(matches[0].id) ||
|
|
80
|
+
matches[0].id < 1 ||
|
|
81
|
+
matches[0].expired ||
|
|
82
|
+
matches[0].workflow_run?.id !== runId ||
|
|
83
|
+
matches[0].workflow_run?.head_sha !== run.head_sha ||
|
|
84
|
+
!/^sha256:[0-9a-f]{64}$/u.test(matches[0].digest || "")
|
|
85
|
+
)
|
|
86
|
+
throw new Error(
|
|
87
|
+
"Version preparation has no unique immutable platform artifact",
|
|
88
|
+
);
|
|
89
|
+
return matches[0];
|
|
90
|
+
});
|
|
91
|
+
const again = await runs.read(runId, runAttempt);
|
|
92
|
+
if (
|
|
93
|
+
again.run.head_sha !== run.head_sha ||
|
|
94
|
+
recordDigest(again.run.referenced_workflows) !==
|
|
95
|
+
recordDigest(run.referenced_workflows) ||
|
|
96
|
+
selected.some(
|
|
97
|
+
(job) =>
|
|
98
|
+
!again.jobs.some((value) => recordDigest(value) === recordDigest(job)),
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
throw new Error(
|
|
102
|
+
"Version preparation producer changed during independent readback",
|
|
103
|
+
);
|
|
104
|
+
const body = {
|
|
105
|
+
schema: "buildchain.pipeline-version-build-readback/v1",
|
|
106
|
+
preparationRoot: preparation.root,
|
|
107
|
+
source: preparation.source,
|
|
108
|
+
runtime: preparation.runtime,
|
|
109
|
+
runId,
|
|
110
|
+
runAttempt,
|
|
111
|
+
definitionSha,
|
|
112
|
+
providerSource: run.head_sha,
|
|
113
|
+
jobs: selected,
|
|
114
|
+
artifacts: assets.map(({ id, digest }) => ({ id, digest })),
|
|
115
|
+
};
|
|
116
|
+
return { build: { ...body, root: recordDigest(body) }, assets };
|
|
117
|
+
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { recordDigest } from "../../release/discussion/envelope.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
readPipelineVersion,
|
|
6
|
-
} from "../../publication/pipeline/version.js";
|
|
3
|
+
import { readPipelineVersion } from "../../publication/pipeline/version.js";
|
|
4
|
+
import { preparedPipelineVersionMaterial } from "../../publication/pipeline/version-material.js";
|
|
7
5
|
import { verifyPipelinePublicationPlan } from "../../publication/pipeline/plan.js";
|
|
8
6
|
|
|
9
7
|
function blobBytes(value, sha) {
|
|
@@ -64,15 +62,15 @@ export function githubPipelineVersion(request, repository) {
|
|
|
64
62
|
sourceTimestamp: commit.committer.date,
|
|
65
63
|
};
|
|
66
64
|
}
|
|
67
|
-
async function materialize(plan) {
|
|
65
|
+
async function materialize(plan, regeneration) {
|
|
68
66
|
verifyPipelinePublicationPlan(plan);
|
|
69
67
|
const input = await inspect(plan.source, plan.versionPolicy);
|
|
70
68
|
if (input.version !== plan.candidateVersion)
|
|
71
69
|
throw new Error("Version material changed after planning");
|
|
72
|
-
const material =
|
|
73
|
-
plan
|
|
70
|
+
const material = preparedPipelineVersionMaterial(
|
|
71
|
+
plan,
|
|
74
72
|
input.files,
|
|
75
|
-
|
|
73
|
+
regeneration,
|
|
76
74
|
);
|
|
77
75
|
let source = plan.source;
|
|
78
76
|
if (material.changes.length)
|
|
@@ -86,12 +84,12 @@ export function githubPipelineVersion(request, repository) {
|
|
|
86
84
|
};
|
|
87
85
|
return { ...body, root: recordDigest(body) };
|
|
88
86
|
}
|
|
89
|
-
async function materializeDevelopment(plan) {
|
|
87
|
+
async function materializeDevelopment(plan, regeneration) {
|
|
90
88
|
const input = await inspect(plan.source, plan.versionPolicy);
|
|
91
|
-
const material =
|
|
92
|
-
plan
|
|
89
|
+
const material = preparedPipelineVersionMaterial(
|
|
90
|
+
plan,
|
|
93
91
|
input.files,
|
|
94
|
-
|
|
92
|
+
regeneration,
|
|
95
93
|
);
|
|
96
94
|
const branch = `feature/buildchain-next/${plan.root.slice(7)}`;
|
|
97
95
|
const source = material.changes.length
|
|
@@ -25,6 +25,7 @@ export async function preparePipelinePublicationAction(core, env) {
|
|
|
25
25
|
host,
|
|
26
26
|
);
|
|
27
27
|
core.setOutput("operation", result.operation);
|
|
28
|
+
core.setOutput("stable-wait", result.wait ? JSON.stringify(result.wait) : "");
|
|
28
29
|
core.setOutput("context", JSON.stringify(result.context || {}));
|
|
29
30
|
core.setOutput(
|
|
30
31
|
"matrix",
|
|
@@ -79,7 +80,13 @@ export async function sealPipelineQualificationAction(core, env) {
|
|
|
79
80
|
|
|
80
81
|
export async function applyPipelinePublicationAction(core, env) {
|
|
81
82
|
const host = await pipelineHost(core, env, "Apply qualified publication");
|
|
82
|
-
await applyPipelinePublication(
|
|
83
|
+
const result = await applyPipelinePublication(
|
|
84
|
+
contextInput(core),
|
|
85
|
+
host,
|
|
86
|
+
directory(env),
|
|
87
|
+
env,
|
|
88
|
+
);
|
|
89
|
+
core.setOutput("stable-wait", result.wait ? JSON.stringify(result.wait) : "");
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
export async function settlePipelinePublicationAction(core, env) {
|
|
@@ -97,4 +104,13 @@ export async function settlePipelinePublicationAction(core, env) {
|
|
|
97
104
|
env,
|
|
98
105
|
);
|
|
99
106
|
core.info(result.reason);
|
|
107
|
+
core.setOutput(
|
|
108
|
+
"version-context",
|
|
109
|
+
JSON.stringify(result.versionContext || {}),
|
|
110
|
+
);
|
|
111
|
+
core.setOutput("operation", result.versionContext ? "regenerate" : "wait");
|
|
112
|
+
core.setOutput(
|
|
113
|
+
"matrix",
|
|
114
|
+
JSON.stringify({ include: result.versionContext?.platforms || [] }),
|
|
115
|
+
);
|
|
100
116
|
}
|