@kungfu-tech/buildchain 3.0.2 → 3.0.3-alpha.0

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 (52) hide show
  1. package/actions/report-buildchain-issue/README.md +1 -1
  2. package/contracts/buildchain-v2-residuals-v1.json +191 -0
  3. package/dist/site/buildchain-contract.json +58 -35
  4. package/dist/site/buildchain-site.json +33 -33
  5. package/dist/site/controller-registry.json +24 -3
  6. package/dist/site/kfd-claims.json +11 -6
  7. package/dist/site/kfd-upstream-aggregate.json +1 -1
  8. package/dist/site/manual-registry.json +7 -7
  9. package/dist/site/node-api-registry.json +6 -6
  10. package/dist/site/page-registry.json +22 -22
  11. package/dist/site/public-surface-audit.json +10 -5
  12. package/dist/site/publication-registry.json +4 -4
  13. package/dist/site/site-manifest.json +11 -11
  14. package/dist/site/workflow-registry.json +8 -3
  15. package/docs/auditable-demo.md +19 -1
  16. package/docs/consumer-issue-reporting.md +1 -1
  17. package/docs/dev-alpha-candidate-patrol.md +24 -10
  18. package/docs/github-governance-authority.md +23 -30
  19. package/docs/observed-evidence-patrol.md +28 -12
  20. package/docs/release-governance.md +8 -1
  21. package/docs/reusable-build-surface.md +124 -64
  22. package/docs/shifu-gate-profiles.md +7 -1
  23. package/docs/versioning.md +41 -21
  24. package/docs/web-surface-deployments.md +20 -1
  25. package/package.json +1 -1
  26. package/packages/core/artifact-signing.js +1 -0
  27. package/packages/core/buildchain-contract.js +1 -0
  28. package/packages/core/controller-evidence.js +2 -0
  29. package/packages/core/github-governance-authority.js +25 -17
  30. package/packages/core/publication-control-plane-audit.js +28 -0
  31. package/packages/core/release-passport.js +36 -27
  32. package/packages/core/stable-release-gate.js +4 -1
  33. package/scripts/artifact-signing-delegation.mjs +268 -0
  34. package/scripts/audit-github-governance.mjs +32 -13
  35. package/scripts/audit-publication-control-plane.mjs +17 -8
  36. package/scripts/auditable-demo.mjs +147 -2
  37. package/scripts/buildchain-patrol.mjs +1 -1
  38. package/scripts/check-inventory.mjs +1 -0
  39. package/scripts/dev-alpha-candidate-patrol.mjs +20 -0
  40. package/scripts/dispatch-artifact-signing-authority.mjs +7 -1
  41. package/scripts/finalize-native-artifact-signing-result.mjs +114 -31
  42. package/scripts/gate-profile-core.mjs +6 -1
  43. package/scripts/inspect-artifact-signing-requests.mjs +45 -14
  44. package/scripts/observed-evidence.mjs +151 -33
  45. package/scripts/reconcile-github-governance.mjs +8 -1
  46. package/scripts/resolve-artifact-signing-upload-route.mjs +55 -0
  47. package/scripts/run-candidate-body-prefix-renderer.mjs +187 -0
  48. package/scripts/runtime-ref-core.mjs +13 -2
  49. package/scripts/seal-artifact-signing-requests.mjs +51 -6
  50. package/scripts/stable-candidate-qualification.mjs +8 -0
  51. package/scripts/verify-artifact-signing-results.mjs +26 -1
  52. package/scripts/web-surface-production-decision.mjs +19 -3
@@ -10,6 +10,34 @@ export const BUILDCHAIN_RELEASE_RECONCILIATION_PATHS = Object.freeze([
10
10
  "package.json",
11
11
  ]);
12
12
 
13
+ function deploymentPatternExpression(pattern) {
14
+ let expression = "";
15
+ for (let index = 0; index < pattern.length; index += 1) {
16
+ const character = pattern[index];
17
+ if (character === "*") {
18
+ if (pattern[index + 1] === "*") {
19
+ expression += ".*";
20
+ index += 1;
21
+ } else {
22
+ expression += "[^/]*";
23
+ }
24
+ } else {
25
+ expression += character.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
26
+ }
27
+ }
28
+ return new RegExp(`^${expression}$`);
29
+ }
30
+
31
+ export function matchesGithubDeploymentPolicy(policy, { ref, refType = "branch" } = {}) {
32
+ const normalizedRef = String(ref || "");
33
+ const normalizedType = String(refType || "");
34
+ const policyName = String(policy?.name || "");
35
+ const policyType = String(policy?.type || "");
36
+ if (!normalizedRef || !["branch", "tag"].includes(normalizedType)) return false;
37
+ if (policyType !== normalizedType || !policyName) return false;
38
+ return deploymentPatternExpression(policyName).test(normalizedRef);
39
+ }
40
+
13
41
  export function evaluateBuildchainReleaseReconciliation({
14
42
  repository,
15
43
  publicationVersion,
@@ -2428,41 +2428,50 @@ export function createReleaseCheckReport({
2428
2428
  };
2429
2429
  }
2430
2430
 
2431
- export async function readJsonFromLocation(location, redirectCount = 0) {
2431
+ export async function readJsonFromLocation(
2432
+ location,
2433
+ redirectCount = 0,
2434
+ { timeoutMs = 15_000 } = {},
2435
+ ) {
2432
2436
  const input = nonEmptyString(location, "location");
2437
+ if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
2438
+ throw new Error("timeoutMs must be a positive integer");
2439
+ }
2433
2440
  if (redirectCount > 5) {
2434
2441
  throw new Error(`too many redirects while reading ${input}`);
2435
2442
  }
2436
2443
  if (/^https?:\/\//.test(input)) {
2437
2444
  const client = input.startsWith("https:") ? https : http;
2438
2445
  return new Promise((resolve, reject) => {
2439
- client
2440
- .get(input, (response) => {
2441
- if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
2442
- const nextLocation = new URL(response.headers.location, input).toString();
2443
- response.resume();
2444
- readJsonFromLocation(nextLocation, redirectCount + 1).then(resolve, reject);
2445
- return;
2446
- }
2447
- if (response.statusCode < 200 || response.statusCode >= 300) {
2448
- reject(new Error(`HTTP ${response.statusCode} while reading ${input}`));
2449
- response.resume();
2450
- return;
2446
+ const request = client.get(input, (response) => {
2447
+ if ([301, 302, 303, 307, 308].includes(response.statusCode) && response.headers.location) {
2448
+ const nextLocation = new URL(response.headers.location, input).toString();
2449
+ response.resume();
2450
+ readJsonFromLocation(nextLocation, redirectCount + 1, { timeoutMs }).then(resolve, reject);
2451
+ return;
2452
+ }
2453
+ if (response.statusCode < 200 || response.statusCode >= 300) {
2454
+ reject(new Error(`HTTP ${response.statusCode} while reading ${input}`));
2455
+ response.resume();
2456
+ return;
2457
+ }
2458
+ let body = "";
2459
+ response.setEncoding("utf8");
2460
+ response.on("data", (chunk) => {
2461
+ body += chunk;
2462
+ });
2463
+ response.on("end", () => {
2464
+ try {
2465
+ resolve(JSON.parse(body));
2466
+ } catch (error) {
2467
+ reject(error);
2451
2468
  }
2452
- let body = "";
2453
- response.setEncoding("utf8");
2454
- response.on("data", (chunk) => {
2455
- body += chunk;
2456
- });
2457
- response.on("end", () => {
2458
- try {
2459
- resolve(JSON.parse(body));
2460
- } catch (error) {
2461
- reject(error);
2462
- }
2463
- });
2464
- })
2465
- .on("error", reject);
2469
+ });
2470
+ });
2471
+ request.setTimeout(timeoutMs, () => {
2472
+ request.destroy(new Error(`timed out after ${timeoutMs}ms while reading ${input}`));
2473
+ });
2474
+ request.on("error", reject);
2466
2475
  });
2467
2476
  }
2468
2477
  return readJsonFile(input);
@@ -194,12 +194,14 @@ export function evaluateStableReleaseGate({
194
194
  : undefined;
195
195
  const attestorAllowed = required.allowedAttestors.length === 0 ||
196
196
  required.allowedAttestors.includes(string(evidence?.attestor));
197
+ const completionTimingValid = required.source === "release-candidate" ||
198
+ (completedAt && completedAt.milliseconds >= candidatePublished.milliseconds);
197
199
  const valid = Boolean(
198
200
  evidence &&
199
201
  string(evidence.status) === "success" &&
200
202
  string(evidence.candidateSha) === candidateSha &&
201
203
  completedAt &&
202
- completedAt.milliseconds >= candidatePublished.milliseconds &&
204
+ completionTimingValid &&
203
205
  attestorAllowed,
204
206
  );
205
207
  if (completedAt) {
@@ -222,6 +224,7 @@ export function evaluateStableReleaseGate({
222
224
  evidenceUrl: string(evidence?.evidenceUrl),
223
225
  attestor: string(evidence?.attestor),
224
226
  attestorAllowed,
227
+ completionTimingValid: Boolean(completionTimingValid),
225
228
  },
226
229
  ));
227
230
  }
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ import { writeGitHubOutputs } from "./build-contract-core.mjs";
7
+
8
+ const CONTRACT = "kungfu-buildchain-artifact-signing-delegation/v1";
9
+
10
+ function required(value, label) {
11
+ const normalized = String(value || "").trim();
12
+ if (!normalized) throw new Error(`${label} is required`);
13
+ if (/[\r\n\0]/u.test(normalized))
14
+ throw new Error(`${label} contains control characters`);
15
+ return normalized;
16
+ }
17
+
18
+ function optional(value, label) {
19
+ const normalized = String(value || "").trim();
20
+ if (/[\r\n\0]/u.test(normalized))
21
+ throw new Error(`${label} contains control characters`);
22
+ return normalized;
23
+ }
24
+
25
+ function exactSha(value, label) {
26
+ const normalized = required(value, label);
27
+ if (!/^[0-9a-f]{40}$/u.test(normalized))
28
+ throw new Error(`${label} must be an exact SHA`);
29
+ return normalized;
30
+ }
31
+
32
+ function positiveInteger(value, label, { allowZero = false } = {}) {
33
+ const normalized = Number(value);
34
+ if (!Number.isSafeInteger(normalized) || normalized < (allowZero ? 0 : 1)) {
35
+ throw new Error(
36
+ `${label} must be ${allowZero ? "a non-negative" : "a positive"} integer`,
37
+ );
38
+ }
39
+ return normalized;
40
+ }
41
+
42
+ function repository(value, label) {
43
+ const normalized = required(value, label);
44
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(normalized)) {
45
+ throw new Error(`${label} must be owner/repository`);
46
+ }
47
+ return normalized;
48
+ }
49
+
50
+ function safeRelativePath(value, label) {
51
+ const normalized = required(value, label).replaceAll("\\", "/");
52
+ const resolved = path.posix.normalize(normalized);
53
+ if (
54
+ resolved === ".." ||
55
+ resolved.startsWith("../") ||
56
+ path.posix.isAbsolute(resolved)
57
+ ) {
58
+ throw new Error(`${label} must be a safe relative path`);
59
+ }
60
+ if (!/^[A-Za-z0-9._ /-]+$/u.test(resolved))
61
+ throw new Error(`${label} contains unsafe shell characters`);
62
+ return resolved;
63
+ }
64
+
65
+ export function validateArtifactSigningDelegation(value) {
66
+ if (!value || typeof value !== "object" || Array.isArray(value))
67
+ throw new Error("delegation must be an object");
68
+ if (value.schemaVersion !== 1 || value.contract !== CONTRACT)
69
+ throw new Error("artifact signing delegation contract mismatch");
70
+ const requestCount = positiveInteger(value.request?.count, "request.count", {
71
+ allowZero: true,
72
+ });
73
+ const delegation = {
74
+ schemaVersion: 1,
75
+ contract: CONTRACT,
76
+ source: {
77
+ repository: repository(value.source?.repository, "source.repository"),
78
+ runId: required(value.source?.runId, "source.runId"),
79
+ runAttempt: positiveInteger(
80
+ value.source?.runAttempt,
81
+ "source.runAttempt",
82
+ ),
83
+ sha: exactSha(value.source?.sha, "source.sha"),
84
+ treeSha: exactSha(value.source?.treeSha, "source.treeSha"),
85
+ },
86
+ runtime: {
87
+ repository: repository(value.runtime?.repository, "runtime.repository"),
88
+ sha: exactSha(value.runtime?.sha, "runtime.sha"),
89
+ },
90
+ platform: {
91
+ id: required(value.platform?.id, "platform.id"),
92
+ name: required(value.platform?.name, "platform.name"),
93
+ },
94
+ request: {
95
+ count: requestCount,
96
+ artifact:
97
+ requestCount > 0
98
+ ? required(value.request?.artifact, "request.artifact")
99
+ : optional(value.request?.artifact, "request.artifact"),
100
+ },
101
+ authority: {
102
+ runId:
103
+ requestCount > 0
104
+ ? required(value.authority?.runId, "authority.runId")
105
+ : optional(value.authority?.runId, "authority.runId"),
106
+ resultArtifact:
107
+ requestCount > 0
108
+ ? required(
109
+ value.authority?.resultArtifact,
110
+ "authority.resultArtifact",
111
+ )
112
+ : optional(
113
+ value.authority?.resultArtifact,
114
+ "authority.resultArtifact",
115
+ ),
116
+ },
117
+ artifact: {
118
+ name: required(value.artifact?.name, "artifact.name"),
119
+ manifestArtifact: required(
120
+ value.artifact?.manifestArtifact,
121
+ "artifact.manifestArtifact",
122
+ ),
123
+ diagnosticsArtifact: required(
124
+ value.artifact?.diagnosticsArtifact,
125
+ "artifact.diagnosticsArtifact",
126
+ ),
127
+ },
128
+ workingDirectory: safeRelativePath(
129
+ value.workingDirectory || ".",
130
+ "workingDirectory",
131
+ ),
132
+ };
133
+ if (
134
+ requestCount === 0 &&
135
+ (delegation.authority.runId || delegation.authority.resultArtifact)
136
+ ) {
137
+ throw new Error(
138
+ "unsigned delegation must not contain authority result coordinates",
139
+ );
140
+ }
141
+ return delegation;
142
+ }
143
+
144
+ export function createArtifactSigningDelegation({
145
+ sourceRepository = process.env.GITHUB_REPOSITORY,
146
+ sourceRunId = process.env.GITHUB_RUN_ID,
147
+ sourceRunAttempt = process.env.GITHUB_RUN_ATTEMPT || "1",
148
+ sourceSha = process.env.BUILDCHAIN_SOURCE_SHA,
149
+ sourceTreeSha = process.env.BUILDCHAIN_SOURCE_TREE_SHA,
150
+ runtimeRepository = process.env.BUILDCHAIN_RUNTIME_REPOSITORY,
151
+ runtimeSha = process.env.BUILDCHAIN_RUNTIME_SHA,
152
+ platformId = process.env.BUILDCHAIN_PLATFORM_ID,
153
+ platformName = process.env.BUILDCHAIN_PLATFORM_NAME,
154
+ requestCount = process.env.BUILDCHAIN_SIGNING_REQUEST_COUNT || "0",
155
+ requestArtifact = process.env.BUILDCHAIN_SIGNING_REQUEST_ARTIFACT || "",
156
+ authorityRunId = process.env.BUILDCHAIN_AUTHORITY_RUN_ID || "",
157
+ resultArtifact = process.env.BUILDCHAIN_SIGNING_RESULT_ARTIFACT || "",
158
+ artifactName = process.env.BUILDCHAIN_ARTIFACT_NAME,
159
+ manifestArtifact = process.env.BUILDCHAIN_MANIFEST_ARTIFACT_NAME,
160
+ diagnosticsArtifact = process.env.BUILDCHAIN_DIAGNOSTICS_ARTIFACT_NAME,
161
+ workingDirectory = process.env.BUILDCHAIN_SIGNING_CWD || ".",
162
+ } = {}) {
163
+ return validateArtifactSigningDelegation({
164
+ schemaVersion: 1,
165
+ contract: CONTRACT,
166
+ source: {
167
+ repository: sourceRepository,
168
+ runId: sourceRunId,
169
+ runAttempt: Number(sourceRunAttempt),
170
+ sha: sourceSha,
171
+ treeSha: sourceTreeSha,
172
+ },
173
+ runtime: { repository: runtimeRepository, sha: runtimeSha },
174
+ platform: { id: platformId, name: platformName },
175
+ request: { count: Number(requestCount), artifact: requestArtifact },
176
+ authority: { runId: authorityRunId, resultArtifact },
177
+ artifact: { name: artifactName, manifestArtifact, diagnosticsArtifact },
178
+ workingDirectory,
179
+ });
180
+ }
181
+
182
+ export function sealArtifactSigningDelegation({
183
+ outputPath = process.env.BUILDCHAIN_SIGNING_DELEGATION_PATH,
184
+ ...values
185
+ } = {}) {
186
+ const delegation = createArtifactSigningDelegation(values);
187
+ const target = path.resolve(required(outputPath, "delegation output path"));
188
+ fs.mkdirSync(path.dirname(target), { recursive: true });
189
+ fs.writeFileSync(target, `${JSON.stringify(delegation, null, 2)}\n`);
190
+ return delegation;
191
+ }
192
+
193
+ export function readArtifactSigningDelegation(
194
+ inputPath = process.env.BUILDCHAIN_SIGNING_DELEGATION_PATH,
195
+ ) {
196
+ const target = path.resolve(required(inputPath, "delegation input path"));
197
+ return validateArtifactSigningDelegation(
198
+ JSON.parse(fs.readFileSync(target, "utf8")),
199
+ );
200
+ }
201
+
202
+ export function artifactSigningDelegationOutputs(delegation) {
203
+ const value = validateArtifactSigningDelegation(delegation);
204
+ return {
205
+ "request-count": String(value.request.count),
206
+ "request-artifact": value.request.artifact,
207
+ "authority-run-id": value.authority.runId,
208
+ "result-artifact": value.authority.resultArtifact,
209
+ "artifact-name": value.artifact.name,
210
+ "manifest-artifact-name": value.artifact.manifestArtifact,
211
+ "diagnostics-artifact-name": value.artifact.diagnosticsArtifact,
212
+ "working-directory": value.workingDirectory,
213
+ };
214
+ }
215
+
216
+ export function assertArtifactSigningDelegationContext(
217
+ delegation,
218
+ {
219
+ sourceRepository = process.env.BUILDCHAIN_EXPECTED_SOURCE_REPOSITORY || "",
220
+ sourceRunId = process.env.BUILDCHAIN_EXPECTED_SOURCE_RUN_ID || "",
221
+ sourceRunAttempt = process.env.BUILDCHAIN_EXPECTED_SOURCE_RUN_ATTEMPT || "",
222
+ sourceSha = process.env.BUILDCHAIN_EXPECTED_SOURCE_SHA || "",
223
+ runtimeRepository = process.env.BUILDCHAIN_EXPECTED_RUNTIME_REPOSITORY ||
224
+ "",
225
+ runtimeSha = process.env.BUILDCHAIN_EXPECTED_RUNTIME_SHA || "",
226
+ platformId = process.env.BUILDCHAIN_EXPECTED_PLATFORM_ID || "",
227
+ } = {},
228
+ ) {
229
+ const value = validateArtifactSigningDelegation(delegation);
230
+ const expectations = [
231
+ [sourceRepository, value.source.repository, "source repository"],
232
+ [sourceRunId, value.source.runId, "source run ID"],
233
+ [sourceRunAttempt, String(value.source.runAttempt), "source run attempt"],
234
+ [sourceSha, value.source.sha, "source SHA"],
235
+ [runtimeRepository, value.runtime.repository, "runtime repository"],
236
+ [runtimeSha, value.runtime.sha, "runtime SHA"],
237
+ [platformId, value.platform.id, "platform ID"],
238
+ ];
239
+ for (const [expected, actual, label] of expectations) {
240
+ if (expected && String(expected) !== actual)
241
+ throw new Error(`artifact signing delegation ${label} mismatch`);
242
+ }
243
+ return value;
244
+ }
245
+
246
+ if (
247
+ process.argv[1] &&
248
+ import.meta.url === pathToFileURL(process.argv[1]).href
249
+ ) {
250
+ try {
251
+ const mode = process.argv[2] || "seal";
252
+ if (mode === "seal") {
253
+ sealArtifactSigningDelegation();
254
+ } else if (mode === "outputs") {
255
+ const delegation = assertArtifactSigningDelegationContext(
256
+ readArtifactSigningDelegation(),
257
+ );
258
+ writeGitHubOutputs(artifactSigningDelegationOutputs(delegation));
259
+ } else {
260
+ throw new Error(`unsupported artifact signing delegation mode: ${mode}`);
261
+ }
262
+ } catch (error) {
263
+ console.error(
264
+ `::error::${String(error?.message || error).replace(/\r?\n/gu, "%0A")}`,
265
+ );
266
+ process.exitCode = 1;
267
+ }
268
+ }
@@ -254,15 +254,33 @@ function addMinutes(iso, minutes) {
254
254
  return new Date(Date.parse(iso) + minutes * 60_000).toISOString();
255
255
  }
256
256
 
257
- function repositorySelector(repositories, requested) {
258
- if (!requested) return repositories;
259
- const exact = repositories.filter((repository) =>
260
- repository.full_name === requested ||
261
- repository.name === requested);
262
- if (exact.length !== 1) {
263
- throw new Error(`repository selector must resolve exactly once: ${requested}`);
257
+ function repositoryVisibility(repository) {
258
+ return String(
259
+ repository.visibility || (repository.private ? "private" : "public"),
260
+ ).toLowerCase();
261
+ }
262
+
263
+ export function selectGithubGovernanceRepositories(
264
+ repositories,
265
+ requested = "",
266
+ descriptor = BUILDCHAIN_GITHUB_GOVERNANCE_AUTHORITY,
267
+ ) {
268
+ const exact = requested
269
+ ? repositories.filter((repository) =>
270
+ repository.full_name === requested || repository.name === requested)
271
+ : repositories;
272
+ if (requested && exact.length !== 1) {
273
+ throw new Error("repository selector must resolve exactly once");
264
274
  }
265
- return exact;
275
+ const managedVisibilities = new Set(
276
+ descriptor.repositoryAdmission.managedVisibilities || ["public"],
277
+ );
278
+ const selected = exact.filter((repository) =>
279
+ managedVisibilities.has(repositoryVisibility(repository)));
280
+ if (requested && selected.length !== 1) {
281
+ throw new Error("repository selector is outside managed governance scope");
282
+ }
283
+ return selected;
266
284
  }
267
285
 
268
286
  export function collectGithubGovernanceAudit({
@@ -291,7 +309,10 @@ export function collectGithubGovernanceAudit({
291
309
  if (!organizationState.ok || !repositoriesState.readable) {
292
310
  throw new Error("organization or managed repository inventory is unreadable; governance audit fails closed");
293
311
  }
294
- const selected = repositorySelector(repositoriesState.repositories, repository);
312
+ const selected = selectGithubGovernanceRepositories(
313
+ repositoriesState.repositories,
314
+ repository,
315
+ );
295
316
  if (targetRef && selected.length !== 1) {
296
317
  throw new Error("--target-ref requires exactly one selected repository");
297
318
  }
@@ -316,9 +337,7 @@ export function collectGithubGovernanceAudit({
316
337
  const diagnostics = [];
317
338
  for (const metadata of selected) {
318
339
  const fullName = String(metadata.full_name || "");
319
- const visibilityClass = String(
320
- metadata.visibility || (metadata.private ? "private" : "public"),
321
- );
340
+ const visibilityClass = repositoryVisibility(metadata);
322
341
  const repositoryIdentityRoot = githubRepositoryIdentityRoot({
323
342
  provider: "github",
324
343
  providerRepositoryId: String(metadata.node_id || metadata.id || ""),
@@ -420,7 +439,7 @@ export function collectGithubGovernanceAudit({
420
439
  }
421
440
  }
422
441
  const visibility = selected.reduce((counts, item) => {
423
- const key = String(item.visibility || (item.private ? "private" : "public"));
442
+ const key = repositoryVisibility(item);
424
443
  counts[key] = Number(counts[key] || 0) + 1;
425
444
  return counts;
426
445
  }, {});
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import {
7
7
  evaluateBuildchainReleaseReconciliation,
8
8
  evaluatePublicationControlPlaneSnapshot,
9
+ matchesGithubDeploymentPolicy,
9
10
  } from "../packages/core/publication-control-plane-audit.js";
10
11
 
11
12
  function flag(name, fallback = "") {
@@ -197,8 +198,10 @@ function main() {
197
198
  const requiredStatusCheck = flag("required-status-check", "check");
198
199
  const jobId = flag("job", "promote");
199
200
  const environment = flag("environment", "none");
200
- const providerEnvironment = environment === "none" ? "" : environment;
201
201
  const branch = flag("branch");
202
+ const environmentRef = flag("environment-ref", branch);
203
+ const environmentRefType = flag("environment-ref-type", "branch");
204
+ const providerEnvironment = environment === "none" ? "" : environment;
202
205
  const sourceSha = flag("source-sha").toLowerCase();
203
206
  const packageName = flag("package", "@kungfu-tech/buildchain");
204
207
  const publisherMode = flag("publisher-mode", "npm-trusted-publisher");
@@ -239,15 +242,19 @@ function main() {
239
242
  const deploymentBranches = environment !== "none" && environmentState.deployment_branch_policy?.custom_branch_policies === true
240
243
  ? githubJson(`repos/${repository}/environments/${encodeURIComponent(environment)}/deployment-branch-policies?per_page=100`, "Environment deployment branch policy")
241
244
  : { branch_policies: [] };
242
- const exactEnvironmentBranchPolicy = (deploymentBranches.branch_policies || []).find((entry) =>
243
- entry?.type === "branch" && entry?.name === branch
245
+ if (!["branch", "tag"].includes(environmentRefType)) {
246
+ throw new Error(`unsupported --environment-ref-type: ${environmentRefType}`);
247
+ }
248
+ const matchingEnvironmentPolicy = (deploymentBranches.branch_policies || []).find((entry) =>
249
+ matchesGithubDeploymentPolicy(entry, { ref: environmentRef, refType: environmentRefType })
244
250
  );
245
251
  const environmentBranchAuthorized = environment !== "none" && (
246
252
  (
253
+ environmentRefType === "branch" &&
247
254
  environmentState.deployment_branch_policy?.protected_branches === true &&
248
255
  branchState.protected === true
249
256
  ) ||
250
- Boolean(exactEnvironmentBranchPolicy)
257
+ Boolean(matchingEnvironmentPolicy)
251
258
  );
252
259
  const oidc = githubJson(`repos/${repository}/actions/oidc/customization/sub`, "OIDC subject policy");
253
260
  if (!["npm-trusted-publisher", "github-token", "oidc-role"].includes(publisherMode)) {
@@ -465,12 +472,14 @@ function main() {
465
472
  environmentState.deployment_branch_policy?.protected_branches === true ||
466
473
  (deploymentBranches.branch_policies || []).length > 0,
467
474
  branchAuthorized: environmentBranchAuthorized,
468
- branchPolicyMode: environmentState.deployment_branch_policy?.protected_branches === true
475
+ branchPolicyMode: environmentRefType === "branch" && environmentState.deployment_branch_policy?.protected_branches === true
469
476
  ? "protected-branches"
470
- : exactEnvironmentBranchPolicy
471
- ? "exact-custom-branch"
477
+ : matchingEnvironmentPolicy
478
+ ? `custom-${environmentRefType}-policy`
472
479
  : "unqualified",
473
- authorizedBranch: exactEnvironmentBranchPolicy?.name || "",
480
+ authorizedBranch: matchingEnvironmentPolicy?.name || "",
481
+ authorizedRef: environmentRef,
482
+ authorizedRefType: environmentRefType,
474
483
  reviewRequired: reviewRules.length > 0,
475
484
  preventSelfReview: reviewRules.some((rule) => rule.prevent_self_review === true),
476
485
  },