@kungfu-tech/buildchain 4.1.3-alpha.2 → 4.1.3-alpha.4

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 (80) hide show
  1. package/architecture/action-taxonomy.json +8 -0
  2. package/architecture/agent-change-map.md +23 -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 +10 -4
  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 +63 -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 +53 -3
  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/discussions/materials.js +12 -4
  39. package/packages/core/providers/github/pipeline-entry-runs.js +203 -0
  40. package/packages/core/providers/github/pipeline-policy.js +44 -17
  41. package/packages/core/providers/github/pipeline-release-evidence.js +111 -0
  42. package/packages/core/providers/github/pipeline-stable-source.js +354 -0
  43. package/packages/core/providers/github/pipeline-version-artifacts.js +91 -0
  44. package/packages/core/providers/github/pipeline-version-readback.js +117 -0
  45. package/packages/core/providers/github/pipeline-version.js +10 -12
  46. package/packages/core/publication/pipeline/actions.js +17 -1
  47. package/packages/core/publication/pipeline/apply.js +23 -17
  48. package/packages/core/publication/pipeline/development-pr.js +28 -1
  49. package/packages/core/publication/pipeline/documents.js +37 -1
  50. package/packages/core/publication/pipeline/next-development.js +1 -0
  51. package/packages/core/publication/pipeline/pack.js +7 -1
  52. package/packages/core/publication/pipeline/plan.js +22 -0
  53. package/packages/core/publication/pipeline/prepare.js +44 -56
  54. package/packages/core/publication/pipeline/qualification.js +5 -0
  55. package/packages/core/publication/pipeline/stable-entry.js +215 -0
  56. package/packages/core/publication/pipeline/stable-products.js +317 -0
  57. package/packages/core/publication/pipeline/stable-wait.js +170 -0
  58. package/packages/core/publication/pipeline/stable.js +82 -0
  59. package/packages/core/publication/pipeline/version-actions.js +93 -0
  60. package/packages/core/publication/pipeline/version-build.js +130 -0
  61. package/packages/core/publication/pipeline/version-context.js +143 -0
  62. package/packages/core/publication/pipeline/version-material.js +36 -0
  63. package/packages/core/publication/pipeline/version-preparation.js +69 -0
  64. package/packages/core/publication/pipeline/version-regeneration.js +162 -0
  65. package/packages/core/publication/pipeline/version-source-guard.js +69 -0
  66. package/packages/core/publication/pipeline/worker.js +72 -0
  67. package/packages/core/release/stable-release-gate.js +11 -0
  68. package/packages/core/workflow/attempt/identity.js +7 -2
  69. package/packages/core/workflow/pipeline/actions.js +14 -1
  70. package/packages/core/workflow/pipeline/build-result.js +8 -0
  71. package/packages/core/workflow/pipeline/build.js +49 -2
  72. package/packages/core/workflow/pipeline/channel-control.js +10 -1
  73. package/packages/core/workflow/pipeline/channel-source.js +53 -0
  74. package/packages/core/workflow/pipeline/group-control.js +2 -0
  75. package/packages/core/workflow/pipeline/host.js +24 -16
  76. package/packages/core/workflow/pipeline/selection.js +9 -4
  77. package/scripts/check-pipeline-publication-topology.mjs +12 -0
  78. package/scripts/check-release-topology.mjs +1 -0
  79. package/scripts/generate-site-bundle.mjs +1 -1
  80. package/scripts/verify-standalone-product.mjs +42 -0
@@ -1,8 +1,7 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs";
3
- import { createHash } from "node:crypto";
4
3
  import { recordDigest } from "../../release/discussion/envelope.js";
5
- import { canonicalJson } from "../../release/discussion/envelope.js";
4
+ import { preparePipelineStableQualification } from "./stable-wait.js";
6
5
  import {
7
6
  createReleaseReceipt,
8
7
  RELEASE_RECEIPT_CONTRACT,
@@ -11,7 +10,10 @@ import { githubPipelineProductRelease } from "../../providers/github/pipeline-pr
11
10
  import { pipelineNpmProvider } from "../npm/pipeline-provider.js";
12
11
  import { publicationContext, uniquePublicationMaterial } from "./context.js";
13
12
  import { restorePipelineProducts } from "./sealed-products.js";
14
- import { pipelineReleaseDocuments } from "./documents.js";
13
+ import {
14
+ pipelineReleaseDocuments,
15
+ pipelineReleaseEvidence,
16
+ } from "./documents.js";
15
17
  import { writeImmutablePublicationFile } from "./files.js";
16
18
  import { verifyPipelineSigning } from "./signing.js";
17
19
  import { pipelinePublicationEffects, applyPipelineEffects } from "./effects.js";
@@ -32,6 +34,17 @@ export async function applyPipelinePublication(
32
34
  } = {},
33
35
  ) {
34
36
  const { journal, archive } = await publicationContext(context, host);
37
+ const wait = await preparePipelineStableQualification(
38
+ context.plan,
39
+ host,
40
+ journal,
41
+ {
42
+ attempt: context.attempt,
43
+ generation: context.generation,
44
+ phase: "publish",
45
+ },
46
+ );
47
+ if (wait) return { operation: "wait", wait };
35
48
  const retained = await uniquePublicationMaterial(
36
49
  journal,
37
50
  "publication/qualified/",
@@ -80,20 +93,13 @@ export async function applyPipelinePublication(
80
93
  artifacts: qualified.artifacts,
81
94
  environment,
82
95
  });
83
- const evidence = Object.entries({
84
- qualification: Buffer.from(`${canonicalJson(qualified)}\n`),
85
- capsules: Buffer.from(`${canonicalJson(capsules)}\n`),
86
- invocation: Buffer.from(
87
- `${canonicalJson(documents.invocation.invocation)}\n`,
88
- ),
89
- attestation: fs.readFileSync(bundlePath),
90
- }).map(([id, bytes]) => ({
91
- id,
92
- name: `buildchain.${id}.json`,
93
- bytes,
94
- size: bytes.length,
95
- digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
96
- }));
96
+ const evidence = pipelineReleaseEvidence({
97
+ plan,
98
+ qualified,
99
+ capsules,
100
+ documents,
101
+ bundle: fs.readFileSync(bundlePath),
102
+ });
97
103
  const github = githubPipelineProductRelease({
98
104
  ...host,
99
105
  plan,
@@ -1,4 +1,8 @@
1
1
  import { recordDigest } from "../../release/discussion/envelope.js";
2
+ import {
3
+ preparePipelineVersionContext,
4
+ retainedPipelineVersionRegeneration,
5
+ } from "./version-context.js";
2
6
 
3
7
  export async function prepareNextDevelopmentPullRequest(
4
8
  context,
@@ -44,8 +48,31 @@ export async function prepareNextDevelopmentPullRequest(
44
48
  phase: "next-development",
45
49
  });
46
50
  }
51
+ const regeneration = await retainedPipelineVersionRegeneration(
52
+ journal,
53
+ request,
54
+ "development",
55
+ host.runtime,
56
+ );
57
+ if (request.versionPolicy.derived_files?.length && !regeneration)
58
+ return {
59
+ state: "waiting",
60
+ reason: "next-development-version-regeneration-required",
61
+ versionContext: await preparePipelineVersionContext(
62
+ request,
63
+ "development",
64
+ context,
65
+ host,
66
+ journal,
67
+ context.recovery?.execution.publisher.workflowSha ||
68
+ plan.publisher.workflowSha,
69
+ ),
70
+ };
47
71
  await journal.fence();
48
- const materialization = await adapter.materializeDevelopment(request);
72
+ const materialization = await adapter.materializeDevelopment(
73
+ request,
74
+ regeneration,
75
+ );
49
76
  const branch = materialization.branch;
50
77
  const pulls = await host.request(
51
78
  `/repos/${host.repository}/pulls?state=all&head=${encodeURIComponent(`${host.repository.split("/")[0]}:${branch}`)}&base=${encodeURIComponent(plan.developmentBranch)}&per_page=100`,
@@ -1,4 +1,8 @@
1
- import { recordDigest } from "../../release/discussion/envelope.js";
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ canonicalJson,
4
+ recordDigest,
5
+ } from "../../release/discussion/envelope.js";
2
6
  import { releaseTailRoot } from "../../release/release-tail-provider-plane.js";
3
7
  import {
4
8
  RELEASE_INVOCATION_CONTRACT,
@@ -12,6 +16,38 @@ import {
12
16
  } from "../publication-qualification.js";
13
17
  import { verifyPipelinePublicationPlan } from "./plan.js";
14
18
 
19
+ export function pipelineReleaseEvidence({
20
+ plan,
21
+ qualified,
22
+ capsules,
23
+ documents,
24
+ bundle,
25
+ }) {
26
+ verifyPipelinePublicationPlan(plan);
27
+ if (plan.evidenceVersion !== undefined && plan.evidenceVersion !== 1)
28
+ throw new Error("Unsupported retained publication evidence version");
29
+ // An older retained transaction keeps its original evidence inventory.
30
+ // Adding a disclosure during recovery would change its provider receipt.
31
+ const values = {
32
+ ...(plan.evidenceVersion === 1 ? { plan } : {}),
33
+ qualification: qualified,
34
+ capsules,
35
+ invocation: documents.invocation.invocation,
36
+ };
37
+ const bytes = Object.entries(values).map(([id, value]) => [
38
+ id,
39
+ Buffer.from(`${canonicalJson(value)}\n`),
40
+ ]);
41
+ bytes.push(["attestation", Buffer.from(bundle)]);
42
+ return bytes.map(([id, value]) => ({
43
+ id,
44
+ name: `buildchain.${id}.json`,
45
+ bytes: value,
46
+ size: value.length,
47
+ digest: `sha256:${createHash("sha256").update(value).digest("hex")}`,
48
+ }));
49
+ }
50
+
15
51
  export function verifyRootedPublication(value, schema) {
16
52
  const { root, ...body } = value || {};
17
53
  if (body.schema !== schema || root !== recordDigest(body))
@@ -46,6 +46,7 @@ export async function nextPipelineDevelopment(context, host, journal) {
46
46
  transition,
47
47
  adapter,
48
48
  );
49
+ if (selected.versionContext) return selected;
49
50
  }
50
51
  const pr = await host.request(
51
52
  `/repos/${host.repository}/pulls/${selected.number}`,
@@ -101,7 +101,13 @@ export function packPipelineProducts({
101
101
  ? packageArtifact(directory, staging, item, plan.version, environment)
102
102
  : inspectPipelineFileArtifact(directory, item);
103
103
  const observed = publicationFile(packed.file);
104
- const name = `${item.product}-${platform}-${item.artifact}${packed.suffix}`;
104
+ const name =
105
+ item.filename ||
106
+ `${item.product}-${platform}-${item.artifact}${packed.suffix}`;
107
+ if (!name.endsWith(packed.suffix))
108
+ throw new Error(
109
+ "Declared publication filename does not match the actual artifact format",
110
+ );
105
111
  writeImmutablePublicationFile(
106
112
  path.join(output, "payloads", name),
107
113
  observed.bytes,
@@ -13,6 +13,7 @@ export function pipelineExpectedProducts(plan) {
13
13
  kind: artifact.kind,
14
14
  directory: product.directory || ".",
15
15
  path: artifact.path,
16
+ ...(artifact.filename ? { filename: artifact.filename } : {}),
16
17
  targets: product.targets
17
18
  .filter((target) => target.artifacts.includes(artifact.id))
18
19
  .map(({ provider, access }) => ({
@@ -31,6 +32,23 @@ export function pipelineExpectedProducts(plan) {
31
32
  throw new Error(
32
33
  "Publication requires a bounded unique product/platform/artifact inventory",
33
34
  );
35
+ const filenames = outputs.map(
36
+ (output) =>
37
+ output.filename ||
38
+ `${output.product}-${output.platform}-${output.artifact}${
39
+ output.kind === "npm-package"
40
+ ? ".tgz"
41
+ : output.kind === "pdf"
42
+ ? ".pdf"
43
+ : [".tar.gz", ".tar.xz", ".tgz", ".zip", ".tar"].find((suffix) =>
44
+ output.path.endsWith(suffix),
45
+ )
46
+ }`,
47
+ );
48
+ if (new Set(filenames).size !== filenames.length)
49
+ throw new Error(
50
+ "Publication artifact filenames must be unique across every declared platform",
51
+ );
34
52
  return outputs;
35
53
  }
36
54
 
@@ -79,6 +97,7 @@ export function planPipelinePublication({
79
97
  );
80
98
  const body = {
81
99
  schema: "buildchain.pipeline-publication-plan/v1",
100
+ evidenceVersion: 1,
82
101
  attempt,
83
102
  generation,
84
103
  source,
@@ -93,6 +112,9 @@ export function planPipelinePublication({
93
112
  route,
94
113
  sourceTimestamp,
95
114
  versionPolicy: contract.version,
115
+ ...(contract.stable
116
+ ? { stablePolicy: structuredClone(contract.stable) }
117
+ : {}),
96
118
  developmentBranch: developmentRoutes[0].from,
97
119
  expectedTagSha,
98
120
  previousChannelCommit,
@@ -1,10 +1,14 @@
1
+ import { claimPipelinePublicationWorker } from "./worker.js";
1
2
  import { recordDigest } from "../../release/discussion/envelope.js";
2
- import { resumePipelineSession } from "../../workflow/pipeline/session.js";
3
3
  import { pipelinePlatforms } from "../../workflow/pipeline/platforms.js";
4
4
  import { githubPipelineVersion } from "../../providers/github/pipeline-version.js";
5
5
  import { createPipelinePublicationPlan } from "./source-plan.js";
6
- import { pipelinePublicationJournal } from "./journal.js";
6
+ import { preparePipelineStableQualification } from "./stable-wait.js";
7
7
  import { prepareRecoveredPublication } from "./recovery-prepare.js";
8
+ import {
9
+ preparePipelineVersionContext,
10
+ retainedPipelineVersionRegeneration,
11
+ } from "./version-context.js";
8
12
 
9
13
  function publicationNextOperation(phase, existing, recovery) {
10
14
  if (phase !== "publish") return "settle";
@@ -15,61 +19,14 @@ function publicationNextOperation(phase, existing, recovery) {
15
19
  }
16
20
 
17
21
  export async function preparePipelinePublication(attempt, publisherSha, host) {
18
- const { run: execution } = await host.runs.read(host.runId, host.runAttempt);
19
- const definitions = (execution.referenced_workflows || []).filter((entry) =>
20
- entry.path?.startsWith(
21
- "kungfu-systems/buildchain/.github/workflows/.release-pipeline-products.yml@",
22
- ),
22
+ const claimed = await claimPipelinePublicationWorker(
23
+ attempt,
24
+ publisherSha,
25
+ host,
23
26
  );
24
- if (
25
- !/^[0-9a-f]{40}$/u.test(publisherSha || "") ||
26
- definitions.length !== 1 ||
27
- definitions[0].sha !== publisherSha
28
- )
29
- throw new Error(
30
- "Publisher definition is not the exact hosted reusable workflow",
31
- );
32
- const session = await resumePipelineSession({ ...host, attempt }, host);
27
+ if (!claimed) return { operation: "wait" };
28
+ const { session, journal, phase } = claimed;
33
29
  const observed = session.observed;
34
- if (observed.phases.merge?.payload.state !== "success")
35
- throw new Error(
36
- "Product publication requires completed protected integration",
37
- );
38
- if (observed.status === "complete") return { operation: "wait" };
39
- const phase = observed.missing[0];
40
- if (!["publish", "distribution", "next-development"].includes(phase))
41
- throw new Error("Attempt is not at product publication");
42
- const prior = observed.history
43
- .at(-1)
44
- .events.filter((event) =>
45
- event.payload.materials.some((material) =>
46
- material.id.startsWith("publication/worker/"),
47
- ),
48
- )
49
- .at(-1)?.payload.writer;
50
- if (
51
- prior &&
52
- (prior.runId !== String(host.runId) ||
53
- prior.runAttempt !== String(host.runAttempt))
54
- ) {
55
- const { run } = await host.runs.read(
56
- Number(prior.runId),
57
- Number(prior.runAttempt),
58
- );
59
- if (run.status !== "completed") return { operation: "wait" };
60
- }
61
- const journal = pipelinePublicationJournal(session, host);
62
- const claim = {
63
- schema: "buildchain.pipeline-publication-worker/v1",
64
- attempt,
65
- runId: host.runId,
66
- runAttempt: host.runAttempt,
67
- };
68
- await journal.record("publication/worker", claim, {
69
- phase,
70
- expectedHead: observed.head,
71
- });
72
- await journal.fence();
73
30
  const recovery = await prepareRecoveredPublication(
74
31
  session,
75
32
  publisherSha,
@@ -91,6 +48,12 @@ export async function preparePipelinePublication(attempt, publisherSha, host) {
91
48
  );
92
49
  await journal.record("publication/plan", plan);
93
50
  }
51
+ const wait = await preparePipelineStableQualification(plan, host, journal, {
52
+ attempt,
53
+ generation: observed.generation,
54
+ phase,
55
+ });
56
+ if (wait) return { operation: "wait", wait };
94
57
  if (
95
58
  !recovery &&
96
59
  (plan.publisher.workflowSha !== publisherSha ||
@@ -106,8 +69,33 @@ export async function preparePipelinePublication(attempt, publisherSha, host) {
106
69
  throw new Error("Publication materialized source is ambiguous");
107
70
  let materialization = materializations[0];
108
71
  if (!materialization) {
72
+ const regeneration = await retainedPipelineVersionRegeneration(
73
+ journal,
74
+ plan,
75
+ "publication",
76
+ host.runtime,
77
+ );
78
+ if (plan.versionPolicy.derived_files?.length && !regeneration) {
79
+ const publication = {
80
+ attempt,
81
+ generation: observed.generation,
82
+ plan,
83
+ ...(recovery ? { recovery } : {}),
84
+ };
85
+ return {
86
+ operation: "regenerate",
87
+ context: await preparePipelineVersionContext(
88
+ plan,
89
+ "publication",
90
+ publication,
91
+ host,
92
+ journal,
93
+ publisherSha,
94
+ ),
95
+ };
96
+ }
109
97
  await journal.fence();
110
- materialization = await version.materialize(plan);
98
+ materialization = await version.materialize(plan, regeneration);
111
99
  const verified = await host.source.source(
112
100
  materialization.source.commit,
113
101
  plan.source.configPath,
@@ -3,6 +3,7 @@ import {
3
3
  publicationArtifactProducer,
4
4
  verifyPublicationBuildAggregate,
5
5
  } from "./build-segments.js";
6
+ import path from "node:path";
6
7
  import { recordDigest } from "../../release/discussion/envelope.js";
7
8
  import { createDomainPublicationQualificationReceipt } from "../publication-qualification.js";
8
9
  import { verifyPipelinePublicationPlan } from "./plan.js";
@@ -22,6 +23,10 @@ function productDescriptor(artifact) {
22
23
  export function inspectPipelinePublicationArtifacts(directory, manifest, plan) {
23
24
  verifyPipelineProductFiles(directory, manifest);
24
25
  for (const artifact of manifest.artifacts) {
26
+ if (artifact.filename && path.basename(artifact.file) !== artifact.filename)
27
+ throw new Error(
28
+ "Independent qualification rejected the declared download filename",
29
+ );
25
30
  if (artifact.kind === "npm-package") {
26
31
  const pkg = readNpmPackageJsonFromTarball(
27
32
  publicationPath(directory, artifact.file),
@@ -0,0 +1,215 @@
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 {
6
+ pipelineEntryRuns,
7
+ pipelineEntryRun,
8
+ pipelineEntryCheck,
9
+ } from "../../providers/github/pipeline-entry-runs.js";
10
+
11
+ async function nativeBuild(check, run, consumer, host) {
12
+ const loaded = await host.index.resolve(check.attempt);
13
+ const observed = readBusinessAttempt(loaded.snapshot);
14
+ const selected = observed.history.find(
15
+ (item) => item.identity.id === check.attempt,
16
+ );
17
+ if (
18
+ observed.intent.repository !== host.repository ||
19
+ !selected ||
20
+ selected.identity.requestKey.startsWith("recover:") ||
21
+ recordDigest(selected.generation.source) !== recordDigest(consumer.source)
22
+ )
23
+ throw new Error(
24
+ "Published entry check points to another source or a recovery attempt",
25
+ );
26
+ const phase = selected.phases.build;
27
+ if (phase?.payload.state !== "success" || check.outcome !== "success")
28
+ return {
29
+ status: "missing",
30
+ reason: "latest-published-entry-build-failed",
31
+ attempt: check.attempt,
32
+ };
33
+ if (
34
+ phase.payload.writer.runId !== String(run.id) ||
35
+ phase.payload.writer.runAttempt !== String(run.run_attempt) ||
36
+ phase.runtime.repository !== "kungfu-systems/buildchain" ||
37
+ phase.runtime.sha !== consumer.selection.ref
38
+ )
39
+ throw new Error(
40
+ "Published entry native build changed its actual runtime or execution",
41
+ );
42
+ const references = phase.payload.materials.filter((item) =>
43
+ item.id.startsWith("build/provider-"),
44
+ );
45
+ if (references.length !== 1)
46
+ throw new Error(
47
+ "Published entry requires one original normal build receipt",
48
+ );
49
+ const session = {
50
+ intent: loaded.snapshot.intent,
51
+ observed: { ...observed, history: [selected] },
52
+ };
53
+ const readback = await host.materialStore(session).read(references[0]);
54
+ verifyRootedPublication(readback, "buildchain.pipeline-build-readback/v1");
55
+ if (
56
+ readback.runId !== run.id ||
57
+ readback.runAttempt !== run.run_attempt ||
58
+ readback.outcome !== "success" ||
59
+ recordDigest(readback.source) !== recordDigest(consumer.source) ||
60
+ recordDigest(readback.entry) !== recordDigest(consumer.entry) ||
61
+ check.summary !==
62
+ `Exact product jobs: ${readback.root}\nAttempt: ${check.attempt}`
63
+ )
64
+ throw new Error("Published entry check and native build material disagree");
65
+ return {
66
+ readback,
67
+ phase,
68
+ reference: references[0],
69
+ attempt: check.attempt,
70
+ intent: observed.intent.id,
71
+ };
72
+ }
73
+
74
+ function completion(run, jobs, native, source) {
75
+ const recorder = jobs.filter(
76
+ (job) => String(job.id) === native.phase.payload.writer.jobId,
77
+ );
78
+ if (
79
+ recorder.length !== 1 ||
80
+ recorder[0].status !== "completed" ||
81
+ recorder[0].conclusion !== "success" ||
82
+ recorder[0].run_id !== run.id ||
83
+ recorder[0].run_attempt !== run.run_attempt ||
84
+ !(
85
+ recorder[0].name === "Record product build" ||
86
+ recorder[0].name.endsWith(" / Record product build")
87
+ )
88
+ )
89
+ throw new Error(
90
+ "Published entry native receipt lacks its exact completed recorder job",
91
+ );
92
+ const selected = [...native.readback.jobs, recorder[0]];
93
+ const start = Date.parse(source.candidate.publishedAt),
94
+ completions = [];
95
+ for (const job of selected) {
96
+ const started = Date.parse(job.started_at),
97
+ ended = Date.parse(job.completed_at);
98
+ if (
99
+ !Number.isFinite(started) ||
100
+ !Number.isFinite(ended) ||
101
+ started < start ||
102
+ ended < started
103
+ )
104
+ throw new Error(
105
+ "Published entry jobs must execute after the exact Alpha publication",
106
+ );
107
+ completions.push(ended);
108
+ }
109
+ return new Date(Math.max(...completions)).toISOString();
110
+ }
111
+
112
+ async function inspectEntryRun(listed, plan, source, host) {
113
+ const { run, jobs } = await host.runs.read(listed.id, listed.run_attempt);
114
+ const consumer = await pipelineEntryRun(run, plan, source, host);
115
+ if (!consumer) return null;
116
+ if (
117
+ !jobs.some((job) => /(?:^| \/ )Build product \([^)]+\)$/u.test(job.name)) &&
118
+ run.conclusion === "success"
119
+ )
120
+ return null;
121
+ const check = await pipelineEntryCheck(run, host);
122
+ if (!check)
123
+ return {
124
+ status: "missing",
125
+ reason: "latest-published-entry-build-unrecorded",
126
+ runId: run.id,
127
+ };
128
+ const native = await nativeBuild(check, run, consumer, host);
129
+ if (native.status === "missing") return native;
130
+ const platforms = [
131
+ ...new Set(plan.outputs.map(({ platform }) => platform)),
132
+ ].sort();
133
+ const fresh = await host.runs.build(
134
+ run.id,
135
+ run.run_attempt,
136
+ consumer.source,
137
+ platforms,
138
+ );
139
+ if (recordDigest(fresh) !== recordDigest(native.readback))
140
+ throw new Error(
141
+ "Published entry original jobs changed or omit declared platforms",
142
+ );
143
+ const completedAt = completion(run, jobs, native, source);
144
+ const again = await host.runs.read(run.id, run.run_attempt);
145
+ const repeated = await nativeBuild(
146
+ await pipelineEntryCheck(again.run, host),
147
+ again.run,
148
+ consumer,
149
+ host,
150
+ );
151
+ if (
152
+ recordDigest(repeated) !== recordDigest(native) ||
153
+ recordDigest(again) !== recordDigest({ run, jobs })
154
+ )
155
+ throw new Error(
156
+ "Published entry evidence changed during final provider readback",
157
+ );
158
+ const body = {
159
+ schema: "buildchain.pipeline-stable-entry/v1",
160
+ planRoot: plan.root,
161
+ sourceRoot: source.root,
162
+ consumer,
163
+ native,
164
+ runId: run.id,
165
+ runAttempt: run.run_attempt,
166
+ canary: {
167
+ id: "published-entry",
168
+ source: "public-build",
169
+ status: "success",
170
+ repository: host.repository,
171
+ candidateSha: source.candidate.sha,
172
+ completedAt,
173
+ attestor: "github-actions[bot]",
174
+ runtimeRef: consumer.selection.ref,
175
+ evidenceUrl: `https://github.com/${host.repository}/actions/runs/${run.id}/attempts/${run.run_attempt}`,
176
+ },
177
+ };
178
+ return { ...body, root: recordDigest(body) };
179
+ }
180
+
181
+ export async function readPipelineStableEntry(plan, source, host) {
182
+ verifyPipelinePublicationPlan(plan);
183
+ verifyRootedPublication(source, "buildchain.pipeline-stable-source/v1");
184
+ if (
185
+ plan.channel !== "stable" ||
186
+ source.planRoot !== plan.root ||
187
+ host.repository !== plan.source.repository ||
188
+ source.contractRoot !== plan.contractRoot ||
189
+ source.candidate.sha !== plan.intentSource.commit ||
190
+ !Number.isFinite(Date.parse(source.candidate.publishedAt))
191
+ )
192
+ throw new Error(
193
+ "Published entry collection requires its exact admitted Alpha plan",
194
+ );
195
+ const inventory = await pipelineEntryRuns(host, source.candidate.publishedAt);
196
+ for (const run of inventory) {
197
+ const result = await inspectEntryRun(run, plan, source, host);
198
+ if (result) {
199
+ if (
200
+ result.canary &&
201
+ recordDigest(
202
+ await pipelineEntryRuns(host, source.candidate.publishedAt),
203
+ ) !== recordDigest(inventory)
204
+ )
205
+ throw new Error(
206
+ "Published entry run inventory changed during qualification",
207
+ );
208
+ return result;
209
+ }
210
+ }
211
+ return {
212
+ status: "missing",
213
+ reason: "no-normal-published-entry-build-for-exact-runtime",
214
+ };
215
+ }