@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
@@ -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
- const result = spawnSync("git", args, {
69
- cwd,
70
- env: { ...process.env, ...env },
71
- input,
72
- encoding,
73
- maxBuffer: 64 * 1024 * 1024,
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(result.stderr || result.stdout || "").trim();
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
- input: patch,
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
- const sourcePatch = git(
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
- encoding: null,
207
+ outputFile: sourcePatch,
192
208
  reason: "source-patch-unreadable",
193
209
  },
194
210
  );
195
- const emptySourcePatch = sourcePatch.length === 0;
211
+ const emptySourcePatch = fs.statSync(sourcePatch).size === 0;
196
212
  if (emptySourcePatch && commits.length !== 1)
197
- throw new ProjectCutAdmissionError("empty-source-composition", "an empty source composition must be exactly one linear commit");
198
- if (!emptySourcePatch) git(cwd, ["apply", "--cached", "--3way", "--whitespace=nowarn", "-"], {
199
- env: indexEnvironment, input: sourcePatch, encoding: "utf8", reason: "project-cut-conflict",
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 = git(
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
- encoding: null,
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 ? replayPatch.length !== 0 ||
230
- sourceNames !== "" || replayNames !== "" : patchId(cwd, sourcePatch) !==
231
- patchId(cwd, replayPatch) || sourceNames !== replayNames;
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"],
@@ -10,10 +10,13 @@ export function discussionMaterials({
10
10
  octokit,
11
11
  repository,
12
12
  intentId,
13
+ partition = "",
13
14
  authorityDescription = "The associated Discussion owns transaction state.",
14
15
  }) {
15
16
  const [owner, repo] = repository.split("/");
16
- const tag = `buildchain-records/${intentId.replace("sha256:", "")}`;
17
+ if (partition && !/^[1-9][0-9]*-[1-9][0-9]*-[1-9][0-9]*$/u.test(partition))
18
+ throw new Error("Invalid material archive writer partition");
19
+ const tag = `buildchain-records/${intentId.replace("sha256:", "")}${partition ? `/${partition}` : ""}`;
17
20
  const repos = octokit.rest.repos;
18
21
  let retainedArchive;
19
22
  async function findArchive() {
@@ -32,8 +35,7 @@ export function discussionMaterials({
32
35
  throw new Error("Ambiguous transaction material archive");
33
36
  return matches[0];
34
37
  }
35
- async function archive() {
36
- if (retainedArchive) return retainedArchive;
38
+ async function createArchive() {
37
39
  let release = await findArchive();
38
40
  if (!release) {
39
41
  try {
@@ -60,9 +62,15 @@ export function discussionMaterials({
60
62
  }
61
63
  if (!release.draft || release.tag_name !== tag)
62
64
  throw new Error("Transaction material archive identity mismatch");
63
- retainedArchive = release;
64
65
  return release;
65
66
  }
67
+ function archive() {
68
+ // Concurrent puts in one writer share the same creation, including readback.
69
+ return (retainedArchive ||= createArchive().catch((error) => {
70
+ retainedArchive = undefined;
71
+ throw error;
72
+ }));
73
+ }
66
74
  async function read(handle) {
67
75
  const response = await repos.getReleaseAsset({
68
76
  owner,
@@ -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
+ }
@@ -1,11 +1,11 @@
1
1
  import { recordDigest } from "../../release/discussion/envelope.js";
2
2
 
3
3
  const QUERY = `query PipelinePolicy($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id number headRefOid baseRefName isDraft reviewDecision author{login}}}}`;
4
- const BRANCH_QUERY = `query PipelineBranchPolicy($owner:String!,$name:String!,$branch:String!,$ref:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$ref){branchProtectionRule{requiresApprovingReviews requiredApprovingReviewCount requiresCodeOwnerReviews requiresStatusChecks requiredStatusChecks{context app{databaseId}}}}mergeQueue(branch:$branch){id}}}`;
4
+ const BRANCH_QUERY = `query PipelineBranchPolicy($owner:String!,$name:String!,$branch:String!,$ref:String!){repository(owner:$owner,name:$name){ref(qualifiedName:$ref){refUpdateRule{requiredApprovingReviewCount requiresCodeOwnerReviews requiredStatusCheckContexts}}mergeQueue(branch:$branch){id}}}`;
5
5
 
6
6
  async function classicPolicy(request, owner, name, branch) {
7
- // REST /branches/:branch/protection requires Administration:read. Ordinary
8
- // workflow credentials instead read effective rules and GraphQL metadata.
7
+ // Read viewer-enforced rules and the ordinary Contents:read branch projection.
8
+ // Administrative branchProtectionRule and /protection queries are not needed.
9
9
  const result = await request("/graphql", {
10
10
  method: "POST",
11
11
  body: {
@@ -14,23 +14,50 @@ async function classicPolicy(request, owner, name, branch) {
14
14
  },
15
15
  });
16
16
  const repository = result.data?.repository;
17
- if (!repository) throw new Error("Protected branch metadata is unavailable");
18
- const rule = repository.ref?.branchProtectionRule;
17
+ if (!repository?.ref)
18
+ throw new Error("Protected branch metadata is unavailable");
19
+ const rule = repository.ref.refUpdateRule;
20
+ const observed = await request(
21
+ `/repos/${owner}/${name}/branches/${encodeURIComponent(branch)}`,
22
+ );
23
+ const status = observed.protection?.required_status_checks;
24
+ const contexts = status?.contexts;
25
+ const checks = status?.checks;
26
+ if (
27
+ observed.name !== branch ||
28
+ !Array.isArray(contexts) ||
29
+ !Array.isArray(checks) ||
30
+ checks.some(
31
+ (check) =>
32
+ typeof check.context !== "string" ||
33
+ !check.context ||
34
+ !Object.hasOwn(check, "app_id") ||
35
+ !(
36
+ check.app_id === null ||
37
+ check.app_id === -1 ||
38
+ (Number.isSafeInteger(check.app_id) && check.app_id > 0)
39
+ ),
40
+ ) ||
41
+ contexts.some(
42
+ (context) => !checks.some((check) => check.context === context),
43
+ ) ||
44
+ checks.some((check) => !contexts.includes(check.context)) ||
45
+ (rule?.requiredStatusCheckContexts || []).some(
46
+ (context) => !contexts.includes(context),
47
+ )
48
+ )
49
+ throw new Error("Protected branch check identity metadata is incomplete");
19
50
  return {
20
51
  required_merge_queue: Boolean(repository.mergeQueue?.id),
21
- required_pull_request_reviews: rule?.requiresApprovingReviews
22
- ? {
23
- require_code_owner_reviews: rule.requiresCodeOwnerReviews,
24
- required_approving_review_count: rule.requiredApprovingReviewCount,
25
- }
26
- : null,
52
+ required_pull_request_reviews:
53
+ rule?.requiredApprovingReviewCount > 0
54
+ ? {
55
+ require_code_owner_reviews: rule.requiresCodeOwnerReviews,
56
+ required_approving_review_count: rule.requiredApprovingReviewCount,
57
+ }
58
+ : null,
27
59
  required_status_checks: {
28
- checks: rule?.requiresStatusChecks
29
- ? (rule.requiredStatusChecks || []).map((check) => ({
30
- context: check.context,
31
- app_id: check.app?.databaseId || null,
32
- }))
33
- : [],
60
+ checks,
34
61
  },
35
62
  };
36
63
  }
@@ -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
+ }