@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.
Files changed (72) hide show
  1. package/architecture/action-taxonomy.json +8 -0
  2. package/architecture/agent-change-map.md +17 -0
  3. package/architecture/ci-lane-change-budget.json +85 -0
  4. package/architecture/decisions/0009-pipeline-version-preparation.md +186 -0
  5. package/architecture/internal-capabilities.json +32 -2
  6. package/architecture/maintainability-debt.json +3 -3
  7. package/architecture/maintainability-policy.json +7 -7
  8. package/architecture/minimal-consumer-migration.json +9 -0
  9. package/architecture/product-upstreams.json +20 -0
  10. package/architecture/release-topology.json +62 -25
  11. package/architecture/universal-workflow-bootstrap.json +2 -0
  12. package/architecture/universal-workflow-capability-policy.json +1 -1
  13. package/architecture/workflow-taxonomy.json +11 -0
  14. package/dist/readers/business-attempt.cjs +6 -2
  15. package/dist/site/buildchain-contract.json +4 -4
  16. package/dist/site/buildchain-site.json +9 -9
  17. package/dist/site/capability-registry.json +2 -2
  18. package/dist/site/kfd-claims.json +82 -8
  19. package/dist/site/kfd-upstream-aggregate.json +1 -1
  20. package/dist/site/manual-registry.json +1 -1
  21. package/dist/site/node-api-registry.json +18 -18
  22. package/dist/site/page-registry.json +4 -4
  23. package/dist/site/public-surface-audit.json +64 -7
  24. package/dist/site/publication-authority-registry.json +21 -1
  25. package/dist/site/publication-registry.json +4 -4
  26. package/dist/site/site-manifest.json +5 -5
  27. package/dist/site/workflow-registry.json +113 -7
  28. package/docs/node-api-reference.md +34 -34
  29. package/docs/workflow-catalog.md +1 -0
  30. package/package.json +1 -1
  31. package/packages/core/build/standalone/build.js +7 -1
  32. package/packages/core/consumer/buildchain-config.js +2 -21
  33. package/packages/core/consumer/contract/plan.js +48 -1
  34. package/packages/core/consumer/contract/products.js +20 -2
  35. package/packages/core/consumer/contract/reader.js +47 -0
  36. package/packages/core/dev-delivery/commands/project-cut-merge-queue-admission.mjs +47 -20
  37. package/packages/core/governance/buildchain-publication-authority.js +1 -0
  38. package/packages/core/providers/github/pipeline-entry-runs.js +203 -0
  39. package/packages/core/providers/github/pipeline-release-evidence.js +111 -0
  40. package/packages/core/providers/github/pipeline-stable-source.js +354 -0
  41. package/packages/core/providers/github/pipeline-version-artifacts.js +91 -0
  42. package/packages/core/providers/github/pipeline-version-readback.js +117 -0
  43. package/packages/core/providers/github/pipeline-version.js +10 -12
  44. package/packages/core/publication/pipeline/actions.js +17 -1
  45. package/packages/core/publication/pipeline/apply.js +23 -17
  46. package/packages/core/publication/pipeline/development-pr.js +28 -1
  47. package/packages/core/publication/pipeline/documents.js +37 -1
  48. package/packages/core/publication/pipeline/next-development.js +1 -0
  49. package/packages/core/publication/pipeline/pack.js +7 -1
  50. package/packages/core/publication/pipeline/plan.js +22 -0
  51. package/packages/core/publication/pipeline/prepare.js +44 -56
  52. package/packages/core/publication/pipeline/qualification.js +5 -0
  53. package/packages/core/publication/pipeline/stable-entry.js +215 -0
  54. package/packages/core/publication/pipeline/stable-products.js +317 -0
  55. package/packages/core/publication/pipeline/stable-wait.js +170 -0
  56. package/packages/core/publication/pipeline/stable.js +82 -0
  57. package/packages/core/publication/pipeline/version-actions.js +93 -0
  58. package/packages/core/publication/pipeline/version-build.js +130 -0
  59. package/packages/core/publication/pipeline/version-context.js +143 -0
  60. package/packages/core/publication/pipeline/version-material.js +36 -0
  61. package/packages/core/publication/pipeline/version-preparation.js +69 -0
  62. package/packages/core/publication/pipeline/version-regeneration.js +162 -0
  63. package/packages/core/publication/pipeline/version-source-guard.js +69 -0
  64. package/packages/core/publication/pipeline/worker.js +72 -0
  65. package/packages/core/release/stable-release-gate.js +11 -0
  66. package/packages/core/workflow/attempt/identity.js +7 -2
  67. package/packages/core/workflow/pipeline/actions.js +14 -1
  68. package/packages/core/workflow/pipeline/build.js +49 -2
  69. package/scripts/check-pipeline-publication-topology.mjs +12 -0
  70. package/scripts/check-release-topology.mjs +1 -0
  71. package/scripts/generate-site-bundle.mjs +1 -1
  72. package/scripts/verify-standalone-product.mjs +42 -0
@@ -0,0 +1,317 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { recordDigest } from "../../release/discussion/envelope.js";
5
+ import { readPipelineReleaseEvidence } from "../../providers/github/pipeline-release-evidence.js";
6
+ import { readPipelineCaller } from "../../providers/github/pipeline-run-entry.js";
7
+ import { verifyPipelinePublicationPlan } from "./plan.js";
8
+ import {
9
+ verifyRootedPublication,
10
+ pipelineReleaseDocuments,
11
+ } from "./documents.js";
12
+ import { verifyPipelineSigning } from "./signing.js";
13
+ import { reobservePublicationBuild } from "./recovery-build-readback.js";
14
+ import { writeImmutablePublicationFile } from "./files.js";
15
+
16
+ function assertPublishedPlan(stablePlan, source, values) {
17
+ const { plan, qualification: qualified, release } = values;
18
+ verifyPipelinePublicationPlan(plan);
19
+ if (
20
+ plan.evidenceVersion !== 1 ||
21
+ plan.channel !== "alpha" ||
22
+ plan.version !== stablePlan.candidateVersion ||
23
+ plan.tag !== source.candidate.tag ||
24
+ plan.contractRoot !== stablePlan.contractRoot ||
25
+ plan.route.operation !== "alpha" ||
26
+ plan.route.to !== stablePlan.route.from ||
27
+ plan.route.from !== stablePlan.developmentBranch ||
28
+ recordDigest(plan.outputs) !== recordDigest(stablePlan.outputs) ||
29
+ recordDigest(qualified.source) !== recordDigest(source.source) ||
30
+ recordDigest(release.source) !== recordDigest(source.source)
31
+ )
32
+ throw new Error(
33
+ "Published product evidence differs from the exact Alpha contract or source",
34
+ );
35
+ if (
36
+ plan.publisher.repository !== "kungfu-systems/buildchain" ||
37
+ plan.publisher.workflow !==
38
+ ".github/workflows/.release-pipeline-products.yml" ||
39
+ plan.publisher.job !== "apply" ||
40
+ !/^[0-9a-f]{40}$/u.test(plan.publisher.workflowSha || "") ||
41
+ plan.runtime.repository !== "kungfu-systems/buildchain"
42
+ )
43
+ throw new Error(
44
+ "Published product evidence requires the central publisher",
45
+ );
46
+ const issued = Date.parse(qualified.qualification?.issuedAt);
47
+ if (
48
+ !Number.isFinite(issued) ||
49
+ issued > Date.parse(source.candidate.publishedAt)
50
+ )
51
+ throw new Error(
52
+ "Published product qualification cannot postdate its Alpha release",
53
+ );
54
+ }
55
+
56
+ function buildLeaves(build) {
57
+ const pending = [build],
58
+ leaves = [];
59
+ let visited = 0;
60
+ while (pending.length) {
61
+ if (++visited > 100)
62
+ throw new Error("Published product build lineage exceeds its bound");
63
+ const value = pending.pop();
64
+ verifyRootedPublication(value, value.schema);
65
+ if (value.schema === "buildchain.pipeline-publication-build-readback/v1")
66
+ leaves.push(value);
67
+ else if (
68
+ value.schema === "buildchain.pipeline-publication-requalification/v1"
69
+ )
70
+ pending.push(value.predecessorBuild);
71
+ else if (
72
+ value.schema ===
73
+ "buildchain.pipeline-publication-build-qualification/v1" &&
74
+ Array.isArray(value.segments) &&
75
+ value.segments.length &&
76
+ value.segments.length <= 100
77
+ )
78
+ pending.push(...value.segments.map(({ build: segment }) => segment));
79
+ else
80
+ throw new Error(
81
+ "Published product build lineage has an unsupported producer",
82
+ );
83
+ }
84
+ return leaves;
85
+ }
86
+
87
+ function assertBuildInventory(plan, qualified) {
88
+ const expected = [
89
+ ...new Set(plan.outputs.map(({ platform }) => platform)),
90
+ ].sort();
91
+ const descriptors = qualified.artifacts
92
+ .map(
93
+ ({
94
+ file,
95
+ size,
96
+ digest,
97
+ package: pkg,
98
+ manifestRoot,
99
+ providerArtifactId,
100
+ ...value
101
+ }) => value,
102
+ )
103
+ .sort((a, b) => a.id.localeCompare(b.id));
104
+ if (recordDigest(descriptors) !== recordDigest(plan.outputs))
105
+ throw new Error(
106
+ "Published product evidence must cover every declared output exactly",
107
+ );
108
+ const leaves = buildLeaves(qualified.build),
109
+ platforms = [],
110
+ jobs = [];
111
+ for (const build of leaves) {
112
+ if (
113
+ build.outcome !== "success" ||
114
+ !Array.isArray(build.platforms) ||
115
+ !Array.isArray(build.jobs) ||
116
+ build.platforms.length !== build.jobs.length ||
117
+ !build.platforms.length ||
118
+ recordDigest(build.source) !== recordDigest(qualified.source)
119
+ )
120
+ throw new Error(
121
+ "Published product evidence lacks complete successful platform builds",
122
+ );
123
+ for (const [index, platform] of build.platforms.entries()) {
124
+ const job = build.jobs[index],
125
+ name = `Build publication (${platform})`;
126
+ if (
127
+ job.status !== "completed" ||
128
+ job.conclusion !== "success" ||
129
+ job.run_id !== build.runId ||
130
+ job.run_attempt !== build.runAttempt ||
131
+ !(job.name === name || job.name.endsWith(` / ${name}`))
132
+ )
133
+ throw new Error(
134
+ "Published product build job does not match its platform or attempt",
135
+ );
136
+ jobs.push(job);
137
+ }
138
+ platforms.push(...build.platforms);
139
+ }
140
+ if (
141
+ recordDigest(platforms.sort()) !== recordDigest(expected) ||
142
+ new Set(jobs.map(({ id }) => id)).size !== jobs.length
143
+ )
144
+ throw new Error(
145
+ "Published product jobs omit or repeat a declared platform",
146
+ );
147
+ return jobs;
148
+ }
149
+
150
+ async function signingRun(plan, qualified, host) {
151
+ const { run, jobs } = await host.runs.read(
152
+ qualified.build.runId,
153
+ qualified.build.runAttempt,
154
+ );
155
+ if (run.head_sha !== qualified.build.providerSource)
156
+ throw new Error("Published product signing run changed its source");
157
+ await readPipelineCaller(
158
+ run,
159
+ qualified.source.configPath,
160
+ host.request,
161
+ host.repository,
162
+ );
163
+ const definitions = (run.referenced_workflows || []).filter((value) =>
164
+ value.path?.startsWith(
165
+ `${plan.publisher.repository}/${plan.publisher.workflow}@`,
166
+ ),
167
+ );
168
+ const selected = jobs.filter(
169
+ (job) =>
170
+ job.name === "Qualify and sign products" ||
171
+ job.name.endsWith(" / Qualify and sign products"),
172
+ );
173
+ if (
174
+ definitions.length !== 1 ||
175
+ definitions[0].sha !== plan.publisher.workflowSha ||
176
+ selected.length !== 1 ||
177
+ selected[0].run_id !== qualified.build.runId ||
178
+ selected[0].run_attempt !== qualified.build.runAttempt ||
179
+ selected[0].status !== "completed" ||
180
+ selected[0].conclusion !== "success"
181
+ )
182
+ throw new Error(
183
+ "Published product signature lacks its exact successful provider job",
184
+ );
185
+ return selected[0];
186
+ }
187
+
188
+ function completionTime(jobs, qualified, candidate) {
189
+ const values = jobs.map((job) => Date.parse(job.completed_at));
190
+ const issued = Date.parse(qualified.qualification.issuedAt);
191
+ if (
192
+ values.some((value) => !Number.isFinite(value)) ||
193
+ values.slice(0, -1).some((value) => value > issued) ||
194
+ values.at(-1) < issued ||
195
+ Math.max(...values) > Date.parse(candidate.publishedAt)
196
+ )
197
+ throw new Error(
198
+ "Published product job timing disagrees with qualification or release",
199
+ );
200
+ return new Date(Math.max(...values)).toISOString();
201
+ }
202
+
203
+ function verifyPublishedDocuments(values, bundle, directory, host, execute) {
204
+ const {
205
+ plan,
206
+ qualification: qualified,
207
+ capsules,
208
+ release,
209
+ invocation,
210
+ } = values;
211
+ const materialization = release.versionMaterialization;
212
+ const signing = verifyPipelineSigning({
213
+ plan,
214
+ materialization,
215
+ qualified,
216
+ bundlePath: writeImmutablePublicationFile(
217
+ path.join(directory, "attestation.json"),
218
+ bundle,
219
+ ),
220
+ directory: path.join(directory, "signature"),
221
+ token: host.token,
222
+ // Historical evidence is checked at issue time. It never supplies fresh
223
+ // Stable publisher admission or extends the original receipt's lifetime.
224
+ evaluatedAt: qualified.qualification.issuedAt,
225
+ ...(execute ? { execute } : {}),
226
+ });
227
+ const expected = pipelineReleaseDocuments({
228
+ plan,
229
+ materialization,
230
+ qualified,
231
+ capsules,
232
+ signing,
233
+ evaluatedAt: qualified.qualification.issuedAt,
234
+ });
235
+ if (
236
+ recordDigest(expected.passport) !== recordDigest(release) ||
237
+ recordDigest(expected.invocation.invocation) !== recordDigest(invocation)
238
+ )
239
+ throw new Error(
240
+ "Published Passport or invocation differs from verified product evidence",
241
+ );
242
+ return signing;
243
+ }
244
+
245
+ export async function readPipelineStableProducts(
246
+ stablePlan,
247
+ source,
248
+ host,
249
+ { execute } = {},
250
+ ) {
251
+ verifyPipelinePublicationPlan(stablePlan);
252
+ verifyRootedPublication(source, "buildchain.pipeline-stable-source/v1");
253
+ if (
254
+ host.repository !== stablePlan.source.repository ||
255
+ source.planRoot !== stablePlan.root ||
256
+ source.contractRoot !== stablePlan.contractRoot ||
257
+ source.source.repository !== host.repository ||
258
+ recordDigest(source.source) !== recordDigest(stablePlan.intentSource) ||
259
+ source.candidate.sha !== source.source.commit ||
260
+ source.candidate.tree !== source.source.tree ||
261
+ source.candidate.tag !== `v${stablePlan.candidateVersion}` ||
262
+ !Number.isFinite(Date.parse(source.candidate.publishedAt)) ||
263
+ stablePlan.channel !== "stable"
264
+ )
265
+ throw new Error(
266
+ "Stable product collection changed its admitted source or repository",
267
+ );
268
+ const evidence = await readPipelineReleaseEvidence(host, source.candidate);
269
+ if (evidence.status === "missing") return evidence;
270
+ assertPublishedPlan(stablePlan, source, evidence.values);
271
+ const { plan, qualification: qualified } = evidence.values;
272
+ const jobs = assertBuildInventory(plan, qualified);
273
+ const directory = fs.mkdtempSync(
274
+ path.join(fs.realpathSync(os.tmpdir()), "buildchain-stable-products-"),
275
+ );
276
+ try {
277
+ const signing = verifyPublishedDocuments(
278
+ evidence.values,
279
+ evidence.bundle,
280
+ directory,
281
+ host,
282
+ execute,
283
+ );
284
+ await reobservePublicationBuild(qualified.build, qualified.source, host);
285
+ const signer = await signingRun(plan, qualified, host);
286
+ const completedAt = completionTime(
287
+ [...jobs, signer],
288
+ qualified,
289
+ source.candidate,
290
+ );
291
+ const body = {
292
+ schema: "buildchain.pipeline-stable-products/v1",
293
+ planRoot: stablePlan.root,
294
+ sourceRoot: source.root,
295
+ alphaPlanRoot: plan.root,
296
+ qualificationRoot: qualified.root,
297
+ signing,
298
+ signer,
299
+ buildRoot: qualified.build.root,
300
+ assets: evidence.assets,
301
+ canary: {
302
+ id: "product-build",
303
+ source: "release-candidate",
304
+ status: "success",
305
+ repository: host.repository,
306
+ candidateSha: source.candidate.sha,
307
+ completedAt,
308
+ attestor: "github-actions[bot]",
309
+ runtimeRef: plan.runtime.commit,
310
+ evidenceUrl: `https://github.com/${host.repository}/actions/runs/${qualified.build.runId}/attempts/${qualified.build.runAttempt}`,
311
+ },
312
+ };
313
+ return { ...body, root: recordDigest(body) };
314
+ } finally {
315
+ fs.rmSync(directory, { recursive: true, force: true });
316
+ }
317
+ }
@@ -0,0 +1,170 @@
1
+ import { recordDigest } from "../../release/discussion/envelope.js";
2
+ import { readBusinessAttempt } from "../../workflow/attempt/reader.js";
3
+ import { verifyRootedPublication } from "./documents.js";
4
+ import { verifyPipelinePublicationPlan } from "./plan.js";
5
+ import { assertPipelineStableQualification } from "./stable.js";
6
+
7
+ const TIMERS = new Set(["stable.minimum_interval", "stable.canary_soak"]);
8
+ const MAX_WAIT = 15 * 60 * 1000;
9
+
10
+ export function planPipelineStableWait(
11
+ plan,
12
+ eligibility,
13
+ { attempt, generation, phase },
14
+ ) {
15
+ verifyPipelinePublicationPlan(plan);
16
+ verifyRootedPublication(
17
+ eligibility,
18
+ "buildchain.pipeline-stable-eligibility/v1",
19
+ );
20
+ if (
21
+ plan.channel !== "stable" ||
22
+ !plan.stablePolicy ||
23
+ eligibility.planRoot !== plan.root ||
24
+ generation !== plan.generation ||
25
+ !/^attempt-[0-9a-f]{64}$/u.test(attempt || "") ||
26
+ !["publish", "distribution", "next-development"].includes(phase)
27
+ )
28
+ throw new Error("Stable wait changed its admitted publication coordinates");
29
+ const failed = eligibility.report.checks.filter(
30
+ (check) => check.status !== "pass",
31
+ );
32
+ if (
33
+ eligibility.report.ok ||
34
+ !failed.length ||
35
+ failed.some((check) => !TIMERS.has(check.id))
36
+ )
37
+ return null;
38
+ const times = failed.map((check) => {
39
+ const interval = check.id === "stable.minimum_interval";
40
+ const expected = interval
41
+ ? plan.stablePolicy.minimum_interval_seconds
42
+ : plan.stablePolicy.minimum_soak_seconds;
43
+ const start = Date.parse(
44
+ interval
45
+ ? check.details.previousPublishedAt
46
+ : check.details.soakStartedAt,
47
+ );
48
+ if (
49
+ check.details.requiredSeconds !== expected ||
50
+ !Number.isSafeInteger(expected) ||
51
+ expected < 0
52
+ )
53
+ throw new Error("Stable wait timer differs from the source-bound policy");
54
+ return start + expected * 1000;
55
+ });
56
+ const ready = Math.max(...times);
57
+ const evaluatedAt = Date.parse(eligibility.evaluatedAt);
58
+ if (
59
+ !Number.isFinite(evaluatedAt) ||
60
+ !Number.isFinite(ready) ||
61
+ ready <= evaluatedAt ||
62
+ ready > 8640000000000000
63
+ )
64
+ throw new Error("Stable wait has no bounded future recheck timestamp");
65
+ const body = {
66
+ schema: "buildchain.pipeline-stable-wait/v1",
67
+ attempt,
68
+ generation,
69
+ phase,
70
+ sourceRoot: recordDigest(plan.intentSource),
71
+ planRoot: plan.root,
72
+ eligibilityRoot: eligibility.root,
73
+ notBefore: new Date(ready).toISOString(),
74
+ };
75
+ return { ...body, root: recordDigest(body) };
76
+ }
77
+
78
+ export async function preparePipelineStableQualification(
79
+ plan,
80
+ host,
81
+ journal,
82
+ coordinates,
83
+ ) {
84
+ try {
85
+ const eligibility = await assertPipelineStableQualification(plan, host);
86
+ if (eligibility) {
87
+ await journal.fence();
88
+ await journal.record("publication/stable-eligibility", eligibility, {
89
+ phase: coordinates.phase,
90
+ });
91
+ }
92
+ return null;
93
+ } catch (error) {
94
+ if (!error.eligibility) throw error;
95
+ const wait = planPipelineStableWait(plan, error.eligibility, coordinates);
96
+ if (!wait) throw error;
97
+ await journal.fence();
98
+ const options = { phase: coordinates.phase, state: "waiting" };
99
+ await journal.record(
100
+ "publication/stable-eligibility",
101
+ error.eligibility,
102
+ options,
103
+ );
104
+ await journal.record("publication/stable-wait", wait, options);
105
+ return wait;
106
+ }
107
+ }
108
+
109
+ export async function retainedPipelineStableWait(observed, host) {
110
+ const current = observed.history.at(-1);
111
+ if (observed.intent.repository !== host.repository)
112
+ throw new Error("Stable wait crossed the native repository boundary");
113
+ const phase = observed.missing[0];
114
+ if (
115
+ !["publish", "distribution", "next-development"].includes(phase) ||
116
+ current.phases[phase]?.payload.state !== "waiting"
117
+ )
118
+ return null;
119
+ const references = current.phases[phase].payload.materials.filter((item) =>
120
+ item.id.startsWith("publication/stable-wait/"),
121
+ );
122
+ if (references.length !== 1) return null;
123
+ const session = { intent: observed.intent, observed };
124
+ const retained = await host.materialStore(session).read(references[0]);
125
+ verifyRootedPublication(retained, "buildchain.pipeline-stable-wait/v1");
126
+ if (
127
+ observed.attempt !== retained.attempt ||
128
+ observed.generation !== retained.generation ||
129
+ recordDigest(current.generation.source) !== retained.sourceRoot ||
130
+ phase !== retained.phase ||
131
+ !Number.isFinite(Date.parse(retained.notBefore))
132
+ )
133
+ throw new Error(
134
+ "Retained stable wait changed its native publication coordinates",
135
+ );
136
+ return retained;
137
+ }
138
+
139
+ async function activeWait(wait, host) {
140
+ const loaded = await host.index.resolve(wait.attempt);
141
+ const observed = readBusinessAttempt(loaded.snapshot);
142
+ const retained = await retainedPipelineStableWait(observed, host);
143
+ if (!retained) return false;
144
+ return recordDigest(retained) === recordDigest(wait);
145
+ }
146
+
147
+ // This node runs outside the publication concurrency group. It only waits and
148
+ // wakes the normal entry; every subsequent publication re-collects qualification.
149
+ export async function wakePipelineStableWait(
150
+ wait,
151
+ host,
152
+ {
153
+ now = () => Date.now(),
154
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
155
+ } = {},
156
+ ) {
157
+ verifyRootedPublication(wait, "buildchain.pipeline-stable-wait/v1");
158
+ const target = Date.parse(wait.notBefore),
159
+ started = now();
160
+ if (!Number.isFinite(target) || !Number.isFinite(started))
161
+ throw new Error("Stable wake requires a finite retained timer");
162
+ const deadline = Math.min(target, started + MAX_WAIT);
163
+ while (now() < deadline) {
164
+ if (!(await activeWait(wait, host))) return { status: "obsolete" };
165
+ await sleep(Math.min(30000, deadline - now()));
166
+ }
167
+ if (!(await activeWait(wait, host))) return { status: "obsolete" };
168
+ await host.wake(wait.attempt);
169
+ return { status: "woken", attempt: wait.attempt, notBefore: wait.notBefore };
170
+ }
@@ -0,0 +1,82 @@
1
+ import { recordDigest } from "../../release/discussion/envelope.js";
2
+ import { evaluateStableReleaseGate } from "../../release/stable-release-gate.js";
3
+ import { readPipelineStableSource } from "../../providers/github/pipeline-stable-source.js";
4
+ import { verifyPipelinePublicationPlan } from "./plan.js";
5
+ import { readPipelineStableProducts } from "./stable-products.js";
6
+ import { readPipelineStableEntry } from "./stable-entry.js";
7
+
8
+ function stablePolicy(plan) {
9
+ const value = plan.stablePolicy;
10
+ const requiredCanaries = [
11
+ {
12
+ id: "product-build",
13
+ source: "release-candidate",
14
+ context: "",
15
+ allowedAttestors: [],
16
+ },
17
+ ];
18
+ if (value.require_published_entry)
19
+ requiredCanaries.push({
20
+ id: "published-entry",
21
+ source: "public-build",
22
+ context: "",
23
+ allowedAttestors: ["github-actions[bot]"],
24
+ });
25
+ return {
26
+ enabled: true,
27
+ minimumStableIntervalSeconds: value.minimum_interval_seconds,
28
+ minimumCanarySoakSeconds: value.minimum_soak_seconds,
29
+ productPathPrefixes: value.product_paths,
30
+ requiredCanaries,
31
+ };
32
+ }
33
+
34
+ export async function assertPipelineStableQualification(
35
+ plan,
36
+ host,
37
+ { now = new Date().toISOString() } = {},
38
+ ) {
39
+ verifyPipelinePublicationPlan(plan);
40
+ if (plan.channel !== "stable" || !plan.stablePolicy) return null;
41
+ const source = await readPipelineStableSource(plan, host);
42
+ const products = await readPipelineStableProducts(plan, source, host);
43
+ const entry =
44
+ plan.stablePolicy.require_published_entry && products.canary
45
+ ? await readPipelineStableEntry(plan, source, host)
46
+ : { status: "not-evaluated" };
47
+ if (
48
+ products.canary &&
49
+ recordDigest(await readPipelineStableSource(plan, host)) !==
50
+ recordDigest(source)
51
+ )
52
+ throw new Error("Stable source facts changed during product qualification");
53
+ const report = evaluateStableReleaseGate({
54
+ policy: stablePolicy(plan),
55
+ channel: "release",
56
+ now,
57
+ candidate: source.candidate,
58
+ previousStable: source.previousStable || undefined,
59
+ impact: source.impact.value,
60
+ changedPaths: source.changedPaths,
61
+ canaries: [products.canary, entry.canary].filter(Boolean),
62
+ });
63
+ report.previousStable ??= null;
64
+ const body = {
65
+ schema: "buildchain.pipeline-stable-eligibility/v1",
66
+ planRoot: plan.root,
67
+ source,
68
+ products,
69
+ entry,
70
+ evaluatedAt: now,
71
+ report,
72
+ };
73
+ const eligibility = { ...body, root: recordDigest(body) };
74
+ if (!report.ok)
75
+ throw Object.assign(
76
+ new Error(
77
+ `Stable pipeline qualification blocked: ${report.summary.failedChecks.join(", ")}`,
78
+ ),
79
+ { eligibility },
80
+ );
81
+ return eligibility;
82
+ }
@@ -0,0 +1,93 @@
1
+ import path from "node:path";
2
+ import { pipelineHost } from "../../workflow/pipeline/host.js";
3
+ import { githubJsonClient } from "../../providers/github/json-client.js";
4
+ import { githubPipelineVersionArtifacts } from "../../providers/github/pipeline-version-artifacts.js";
5
+ import { buildPipelineVersionMaterial } from "./version-build.js";
6
+ import { qualifyPipelineVersionContext } from "./version-context.js";
7
+ import { preparePipelinePublication } from "./prepare.js";
8
+ import { settlePipelinePublication } from "./settle.js";
9
+
10
+ const contextInput = (core) =>
11
+ JSON.parse(core.getInput("context", { required: true }));
12
+ const directory = (env) =>
13
+ path.join(env.GITHUB_WORKSPACE, ".buildchain/version-material");
14
+
15
+ export async function buildPipelineVersionAction(core, env) {
16
+ const context = contextInput(core);
17
+ if (
18
+ context.schema !== "buildchain.pipeline-version-context/v1" ||
19
+ context.runId !== Number(env.GITHUB_RUN_ID) ||
20
+ context.runAttempt !== Number(env.GITHUB_RUN_ATTEMPT)
21
+ )
22
+ throw new Error(
23
+ "Version build requires its exact provider execution context",
24
+ );
25
+ const result = await buildPipelineVersionMaterial({
26
+ cwd: path.join(env.GITHUB_WORKSPACE, ".buildchain/product"),
27
+ preparation: context.preparation,
28
+ platform: core.getInput("platform", { required: true }),
29
+ environment: env,
30
+ });
31
+ await githubPipelineVersionArtifacts({}).upload(
32
+ context,
33
+ result,
34
+ directory(env),
35
+ );
36
+ }
37
+
38
+ export async function materializePipelinePublicationAction(core, env) {
39
+ const context = contextInput(core);
40
+ const operation = core.getInput("operation", { required: true });
41
+ let result = { operation, context };
42
+ if (operation === "regenerate") {
43
+ if (context.preparation.purpose !== "publication")
44
+ throw new Error(
45
+ "Publication materialization received a different version purpose",
46
+ );
47
+ const host = await pipelineHost(core, env, "Materialize publication");
48
+ await qualifyPipelineVersionContext(context, host, directory(env));
49
+ result = await preparePipelinePublication(
50
+ context.attempt,
51
+ context.definitionSha,
52
+ host,
53
+ );
54
+ if (result.operation === "regenerate")
55
+ throw new Error(
56
+ "Qualified version preparation did not materialize its source",
57
+ );
58
+ }
59
+ core.setOutput("operation", result.operation);
60
+ core.setOutput("stable-wait", result.wait ? JSON.stringify(result.wait) : "");
61
+ core.setOutput("context", JSON.stringify(result.context || {}));
62
+ core.setOutput(
63
+ "matrix",
64
+ JSON.stringify({ include: result.context?.platforms || [] }),
65
+ );
66
+ }
67
+
68
+ export async function materializePipelineDevelopmentAction(core, env) {
69
+ const context = contextInput(core);
70
+ if (context.preparation.purpose !== "development")
71
+ throw new Error(
72
+ "Development materialization received a different version purpose",
73
+ );
74
+ const host = await pipelineHost(core, env, "Materialize next development");
75
+ const prToken = core.getInput("pr-token", { required: true });
76
+ if (prToken !== host.token)
77
+ host.pullRequests = githubJsonClient({
78
+ token: prToken,
79
+ userAgent: "buildchain-next-development",
80
+ });
81
+ await qualifyPipelineVersionContext(context, host, directory(env));
82
+ const result = await settlePipelinePublication(
83
+ context.publication,
84
+ host,
85
+ directory(env),
86
+ env,
87
+ );
88
+ if (result.versionContext)
89
+ throw new Error(
90
+ "Qualified development preparation did not materialize its source",
91
+ );
92
+ core.info(result.reason);
93
+ }