@kungfu-tech/buildchain 3.0.1 → 3.0.2-alpha.1

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 (64) hide show
  1. package/README.md +5 -3
  2. package/actions/promote-buildchain-ref/README.md +7 -0
  3. package/bin/buildchain.mjs +173 -513
  4. package/bin/internal/cli-options.mjs +79 -0
  5. package/bin/internal/trust-release-cli.mjs +469 -0
  6. package/dist/site/agent-index.json +2 -0
  7. package/dist/site/artifact-schemas.json +5 -0
  8. package/dist/site/badge-endpoint-registry.json +18 -18
  9. package/dist/site/badges/v1/kfd-12/aligned.json +1 -1
  10. package/dist/site/badges/v1/kfd-12/declared.json +1 -1
  11. package/dist/site/badges/v1/kfd-12/downgraded.json +1 -1
  12. package/dist/site/badges/v1/kfd-12/draft.json +1 -1
  13. package/dist/site/badges/v1/kfd-12/failed.json +1 -1
  14. package/dist/site/badges/v1/kfd-12/missing.json +1 -1
  15. package/dist/site/badges/v1/kfd-12/passed.json +1 -1
  16. package/dist/site/badges/v1/kfd-12/planned.json +1 -1
  17. package/dist/site/badges/v1/kfd-13/aligned.json +1 -1
  18. package/dist/site/badges/v1/kfd-13/declared.json +1 -1
  19. package/dist/site/badges/v1/kfd-13/downgraded.json +1 -1
  20. package/dist/site/badges/v1/kfd-13/draft.json +1 -1
  21. package/dist/site/badges/v1/kfd-13/failed.json +1 -1
  22. package/dist/site/badges/v1/kfd-13/missing.json +1 -1
  23. package/dist/site/badges/v1/kfd-13/passed.json +1 -1
  24. package/dist/site/badges/v1/kfd-13/planned.json +1 -1
  25. package/dist/site/buildchain-contract.json +68 -28
  26. package/dist/site/buildchain-site.json +28 -18
  27. package/dist/site/capability-registry.json +3 -3
  28. package/dist/site/cli-registry.json +108 -0
  29. package/dist/site/controller-registry.json +36 -4
  30. package/dist/site/kfd-claims.json +156 -12
  31. package/dist/site/kfd-upstream-aggregate.json +20 -11
  32. package/dist/site/manual-registry.json +3 -3
  33. package/dist/site/node-api-registry.json +36 -10
  34. package/dist/site/page-registry.json +20 -10
  35. package/dist/site/public-surface-audit.json +122 -10
  36. package/dist/site/publication-registry.json +4 -4
  37. package/dist/site/release-passport-check-manifest.json +11 -0
  38. package/dist/site/release-provenance.json +4 -0
  39. package/dist/site/schemas/kfd-product-gate-input-v1.schema.json +164 -0
  40. package/dist/site/schemas/kfd-support-projection-v1.schema.json +106 -0
  41. package/dist/site/schemas/release-passport-v1.schema.json +3 -0
  42. package/dist/site/site-manifest.json +7 -7
  43. package/dist/site/workflow-registry.json +17 -5
  44. package/docs/cli.md +9 -2
  45. package/docs/kfd-support.md +43 -7
  46. package/docs/release-passport.md +15 -0
  47. package/package.json +7 -3
  48. package/packages/core/cache-evidence.js +288 -0
  49. package/packages/core/diagnostics.js +276 -10
  50. package/packages/core/github-governance-authority.js +71 -16
  51. package/packages/core/index.js +27 -0
  52. package/packages/core/kfd-product-gates.js +865 -0
  53. package/packages/core/kfd.js +16 -7
  54. package/packages/core/kfd3-surface-register.js +105 -1
  55. package/packages/core/public-surface-audit.js +6 -1
  56. package/packages/core/release-passport-contract.js +13 -0
  57. package/packages/core/release-passport.js +87 -3
  58. package/scripts/check-internal-architecture.mjs +171 -0
  59. package/scripts/check-inventory.mjs +4 -0
  60. package/scripts/generate-site-bundle.mjs +24 -0
  61. package/scripts/locked-source-checkout.mjs +48 -0
  62. package/scripts/promotion-routing-evidence.mjs +73 -0
  63. package/scripts/reconcile-github-governance.mjs +158 -25
  64. package/scripts/shifu-gate-profile.mjs +26 -20
@@ -15,6 +15,10 @@ import {
15
15
  collectKfdUpstreamFacts,
16
16
  } from "../packages/core/kfd.js";
17
17
  import { KFD_AGENT_HUB_ADOPTION_SCHEMA } from "../packages/core/kfd-agent-hub.js";
18
+ import {
19
+ KFD_PRODUCT_GATE_INPUT_SCHEMA,
20
+ KFD_SUPPORT_PROJECTION_SCHEMA,
21
+ } from "../packages/core/kfd-product-gates.js";
18
22
  import {
19
23
  createReadmeBadgeEndpointRegistry,
20
24
  } from "../packages/core/readme-badges.js";
@@ -445,6 +449,15 @@ function cliCommandMeta(id) {
445
449
  "kfd-3-register": { group: "kfd-trust", purpose: "Declare detected KFD-3 public surfaces into a product-owned surface registry." },
446
450
  "kfd-3-witness": { group: "kfd-trust", purpose: "Generate release-passport-compatible KFD-3 surface witnesses from a product registry." },
447
451
  "kfd-4-schema": { group: "kfd-trust", purpose: "Print the default KFD-4 schema exposed by the KFD package standards metadata." },
452
+ "kfd-4-gate": { group: "kfd-trust", purpose: "Evaluate source-bound KFD-4 observer-perspective and contrastive-replay product evidence." },
453
+ "kfd-4-verify": { group: "kfd-trust", purpose: "Verify a retained KFD-4 product-gate result without widening product support." },
454
+ "kfd-5-schema": { group: "kfd-trust", purpose: "Print the default KFD-5 schema exposed by the KFD package standards metadata." },
455
+ "kfd-5-gate": { group: "kfd-trust", purpose: "Evaluate source-bound KFD-5 Primitive discovery qualification evidence." },
456
+ "kfd-5-verify": { group: "kfd-trust", purpose: "Verify a retained KFD-5 product-gate result without widening product support." },
457
+ "kfd-7-schema": { group: "kfd-trust", purpose: "Print the default KFD-7 schema exposed by the KFD package standards metadata." },
458
+ "kfd-7-gate": { group: "kfd-trust", purpose: "Evaluate a source-bound KFD-7 Domain Profile, evidence obligations, and independent review." },
459
+ "kfd-7-verify": { group: "kfd-trust", purpose: "Verify a retained KFD-7 product-gate result without widening product support." },
460
+ "kfd-support": { group: "kfd-trust", purpose: "Project and verify a product-owned KFD-1 through KFD-13 support matrix against KFD-4/5/7 gates." },
448
461
  "kfd-aggregate": { group: "kfd-trust", purpose: "Return a product KFD view that combines own KFD status with upstream KFD aggregate facts." },
449
462
  "kfd-upstream": { group: "kfd-trust", purpose: "Inspect KFD upstream aggregate command families." },
450
463
  "kfd-upstream-check": { group: "kfd-trust", purpose: "Validate a KFD upstream aggregate document and fail closed on missing evidence." },
@@ -503,6 +516,7 @@ function nodeApiMeta(exportName) {
503
516
  "./homebrew": { group: "distribution-indexes", summary: "Homebrew tap fact collection, Formula rendering, update, and check APIs." },
504
517
  "./build-facts": { group: "observability-diagnostics", summary: "Git source, version, module output, product artifact, and legacy Kungfu build fact APIs." },
505
518
  "./candidate-timeline": { group: "observability-diagnostics", summary: "Source-bound candidate event normalization, per-attempt critical-path-safe aggregation, and compact reporting APIs." },
519
+ "./cache-evidence": { group: "observability-diagnostics", summary: "Content-addressed cache operation receipts and source/platform-bound evidence-set verification APIs." },
506
520
  "./diagnostics": { group: "observability-diagnostics", summary: "Native diagnostics collection, summarization, cache, compiler, and process-sampler APIs." },
507
521
  "./logging": { group: "observability-diagnostics", summary: "Buildchain JSONL logging, span, summary, and verification APIs." },
508
522
  "./portable-dev-cache": { group: "observability-diagnostics", summary: "Portable dependency/compiler cache plan, exact-root verification, and provider receipt APIs." },
@@ -529,6 +543,7 @@ function nodeApiMeta(exportName) {
529
543
  "./issue-reporting": { group: "observability-diagnostics", summary: "Buildchain-owned issue reporting API for workflow friction feedback." },
530
544
  "./buildchain-layout": { group: "kfd-trust", summary: "Versioned Buildchain repository-layout discovery contract plus canonical .buildchain path resolution and migration APIs." },
531
545
  "./kfd": { group: "kfd-trust", summary: "Unified KFD standards, schema discovery, KFD-1/KFD-2/KFD-3 grouped APIs, KFD-4 schema discovery, upstream KFD aggregate facts, and Buildchain KFD claim helpers." },
546
+ "./kfd-product-gates": { group: "kfd-trust", summary: "Fail-closed KFD-4, KFD-5, and KFD-7 product evidence gates plus non-widening KFD support-matrix release projections." },
532
547
  "./public-surface-audit": { group: "kfd-trust", summary: "Reverse audit APIs for CLI, workflow, action, site page, and documentation command surfaces." },
533
548
  "./kfd-gate": { group: "kfd-trust", summary: "KFD-1/KFD-2/KFD-3 release gate evidence and validation APIs." },
534
549
  "./buildchain-kfd-claims": { group: "kfd-trust", summary: "Buildchain self KFD claim registry, witnesses, and public claim APIs." },
@@ -993,6 +1008,9 @@ function buildSiteBundle() {
993
1008
  "release-passport-check-manifest.json",
994
1009
  "schemas/release-passport-v1.schema.json",
995
1010
  "schemas/kfd-agent-hub-adoption.schema.json",
1011
+ "schemas/kfd-product-gate-input-v1.schema.json",
1012
+ "schemas/kfd-support-projection-v1.schema.json",
1013
+ "kfd-support.json",
996
1014
  "artifact-evidence.json",
997
1015
  "product-mechanism.json",
998
1016
  "impact.json",
@@ -1021,6 +1039,8 @@ function buildSiteBundle() {
1021
1039
  "release-passport-check-manifest.json",
1022
1040
  "schemas/release-passport-v1.schema.json",
1023
1041
  "schemas/kfd-agent-hub-adoption.schema.json",
1042
+ "schemas/kfd-product-gate-input-v1.schema.json",
1043
+ "schemas/kfd-support-projection-v1.schema.json",
1024
1044
  "buildchain-contract.json",
1025
1045
  "kfd-claims.json",
1026
1046
  "product-mechanism.json",
@@ -1150,6 +1170,8 @@ function buildSiteBundle() {
1150
1170
  "release-passport-check-manifest.json",
1151
1171
  "schemas/release-passport-v1.schema.json",
1152
1172
  "schemas/kfd-agent-hub-adoption.schema.json",
1173
+ "schemas/kfd-product-gate-input-v1.schema.json",
1174
+ "schemas/kfd-support-projection-v1.schema.json",
1153
1175
  "buildchain-contract.json",
1154
1176
  "kfd-upstream-aggregate.json",
1155
1177
  "kfd-claims.json",
@@ -1323,6 +1345,8 @@ function buildSiteBundle() {
1323
1345
  "release-passport-check-manifest.json": createReleasePassportCheckManifest(),
1324
1346
  "schemas/release-passport-v1.schema.json": RELEASE_PASSPORT_SCHEMA,
1325
1347
  "schemas/kfd-agent-hub-adoption.schema.json": KFD_AGENT_HUB_ADOPTION_SCHEMA,
1348
+ "schemas/kfd-product-gate-input-v1.schema.json": KFD_PRODUCT_GATE_INPUT_SCHEMA,
1349
+ "schemas/kfd-support-projection-v1.schema.json": KFD_SUPPORT_PROJECTION_SCHEMA,
1326
1350
  "badge-endpoint-registry.json": badgeEndpointRegistry,
1327
1351
  "publication-registry.json": publicationRegistry,
1328
1352
  "buildchain-contract.json": createBuildchainContractWorld({ root, controllerRegistry }),
@@ -177,6 +177,21 @@ function gitObjectDirectory(referencePath) {
177
177
  return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) || "";
178
178
  }
179
179
 
180
+ function directoryBytes(directory) {
181
+ if (!fs.existsSync(directory)) return 0;
182
+ let bytes = 0;
183
+ const pending = [directory];
184
+ while (pending.length) {
185
+ const current = pending.pop();
186
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
187
+ const child = path.join(current, entry.name);
188
+ if (entry.isDirectory()) pending.push(child);
189
+ else if (entry.isFile()) bytes += fs.statSync(child).size;
190
+ }
191
+ }
192
+ return bytes;
193
+ }
194
+
180
195
  function writeAlternates(targetPath, referencePath) {
181
196
  const objectDirectory = gitObjectDirectory(referencePath);
182
197
  if (!objectDirectory) {
@@ -413,6 +428,13 @@ export function lockedSourceCheckout({
413
428
  fallbackUsed: normalizedMode === "off",
414
429
  fallbackReason: normalizedMode === "off" ? "cache disabled" : "",
415
430
  githubFetchAttempts: 0,
431
+ lookupDurationMs: 0,
432
+ restoreDurationMs: 0,
433
+ githubFetchDurationMs: 0,
434
+ restoredBytes: null,
435
+ restoredBytesStatus:
436
+ normalizedMode === "off" ? "not-applicable" : "unavailable",
437
+ restoredBytesMethod: "",
416
438
  },
417
439
  verification: {
418
440
  head: "",
@@ -425,8 +447,13 @@ export function lockedSourceCheckout({
425
447
  let checkoutError;
426
448
  let checkoutSha = sha;
427
449
  if (normalizedMode !== "off") {
450
+ const cacheAttemptStartedAt = Date.now();
451
+ const objectBytesBefore = directoryBytes(
452
+ path.join(targetPath, ".git", "objects"),
453
+ );
428
454
  try {
429
455
  if (renderedReferenceRepository) {
456
+ const lookupStartedAt = Date.now();
430
457
  const alternates = writeAlternates(targetPath, renderedReferenceRepository);
431
458
  evidence.cache.transport = "reference-repository";
432
459
  evidence.cache.referenceAvailable = alternates;
@@ -436,9 +463,13 @@ export function lockedSourceCheckout({
436
463
  if (!hasCommit(targetPath, sha, timeoutMs)) {
437
464
  throw new Error("reference repository does not contain source commit");
438
465
  }
466
+ evidence.cache.lookupDurationMs = Date.now() - lookupStartedAt;
467
+ const restoreStartedAt = Date.now();
439
468
  checkoutFetchedCommit(targetPath, sha, timeoutMs);
469
+ evidence.cache.restoreDurationMs = Date.now() - restoreStartedAt;
440
470
  } else if (renderedMirrorUrl) {
441
471
  evidence.cache.transport = "mirror-url";
472
+ const restoreStartedAt = Date.now();
442
473
  const fetchResult = fetchSourceCommit({
443
474
  targetPath,
444
475
  remoteName: "buildchain-cache",
@@ -452,15 +483,29 @@ export function lockedSourceCheckout({
452
483
  evidence.cache.fetchMode = fetchResult.fetchMode;
453
484
  checkoutSha = fetchResult.checkoutSha || sha;
454
485
  checkoutFetchedCommit(targetPath, checkoutSha, timeoutMs);
486
+ evidence.cache.restoreDurationMs = Date.now() - restoreStartedAt;
455
487
  } else {
456
488
  throw new Error("checkout cache is enabled but no mirror URL or reference repository template was provided");
457
489
  }
458
490
  evidence.cache.hit = true;
459
491
  evidence.cache.fallbackUsed = false;
492
+ evidence.cache.restoredBytes = Math.max(
493
+ 0,
494
+ directoryBytes(path.join(targetPath, ".git", "objects")) -
495
+ objectBytesBefore,
496
+ );
497
+ evidence.cache.restoredBytesStatus = "observed";
498
+ evidence.cache.restoredBytesMethod = "git-object-store-delta";
460
499
  } catch (error) {
461
500
  checkoutError = error;
462
501
  evidence.cache.hit = false;
463
502
  evidence.cache.fallbackReason = error.message;
503
+ if (
504
+ evidence.cache.lookupDurationMs === 0 &&
505
+ evidence.cache.restoreDurationMs === 0
506
+ ) {
507
+ evidence.cache.lookupDurationMs = Date.now() - cacheAttemptStartedAt;
508
+ }
464
509
  if (normalizedMode === "require" || normalizedFallback === "fail") {
465
510
  evidence.durationMs = Date.now() - startedAt;
466
511
  writeEvidence(path.resolve(workspace, diagnosticsPath), evidence);
@@ -477,6 +522,7 @@ export function lockedSourceCheckout({
477
522
  evidence.cache.fallbackReason = checkoutError.message;
478
523
  }
479
524
  try {
525
+ const githubFetchStartedAt = Date.now();
480
526
  const fetchResult = runBoundedFetch({
481
527
  attempts: normalizedFetchAttempts,
482
528
  fetch: () => fetchSourceCommit({
@@ -496,6 +542,8 @@ export function lockedSourceCheckout({
496
542
  evidence.cache.githubFetchAttempts = fetchResult.attempts;
497
543
  evidence.cache.fetchMode = fetchResult.value.fetchMode;
498
544
  checkoutSha = fetchResult.value.checkoutSha || sha;
545
+ evidence.cache.githubFetchDurationMs =
546
+ Date.now() - githubFetchStartedAt;
499
547
  } catch (error) {
500
548
  evidence.durationMs = Date.now() - startedAt;
501
549
  writeEvidence(path.resolve(workspace, diagnosticsPath), evidence);
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ function createPromotionRoutingEvidence({
8
+ env = process.env,
9
+ cwd = process.cwd(),
10
+ } = {}) {
11
+ const routing = {
12
+ schemaVersion: 1,
13
+ contract: "buildchain.promotion-routing/v1",
14
+ router: { ref: env.ROUTER_REF, sha: env.ROUTER_SHA },
15
+ shell: {
16
+ channel: env.SHELL_CHANNEL,
17
+ ref: env.SHELL_REF,
18
+ sha: env.SHELL_SHA,
19
+ },
20
+ runtime: {
21
+ requestedRef: env.RUNTIME_REF,
22
+ resolvedSha: env.RUNTIME_SHA,
23
+ },
24
+ contractLock: { path: env.LOCK_PATH, digest: env.LOCK_DIGEST },
25
+ publication: {
26
+ channel: env.PUBLICATION_CHANNEL,
27
+ targetRef: env.TARGET_REF,
28
+ },
29
+ trustedOverrideUsed: env.OVERRIDE_USED === "true",
30
+ };
31
+ const controllerDir = path.join(cwd, ".buildchain", "controller");
32
+ const routingPath = path.join(controllerDir, "promotion-routing.json");
33
+ const candidatePath = path.join(
34
+ controllerDir,
35
+ "release-candidate-promotion-passport.json",
36
+ );
37
+ const candidate = JSON.parse(
38
+ fs.readFileSync(path.resolve(cwd, env.RELEASE_CANDIDATE_PASSPORT), "utf8"),
39
+ );
40
+ fs.mkdirSync(controllerDir, { recursive: true });
41
+ fs.writeFileSync(routingPath, `${JSON.stringify(routing, null, 2)}\n`);
42
+ fs.writeFileSync(
43
+ candidatePath,
44
+ `${JSON.stringify({ ...candidate, promotionRouting: routing }, null, 2)}\n`,
45
+ );
46
+ if (env.GITHUB_OUTPUT) {
47
+ fs.appendFileSync(
48
+ env.GITHUB_OUTPUT,
49
+ [
50
+ "routing-path=.buildchain/controller/promotion-routing.json",
51
+ "candidate-passport-path=.buildchain/controller/release-candidate-promotion-passport.json",
52
+ "",
53
+ ].join("\n"),
54
+ );
55
+ }
56
+ return { routing, routingPath, candidatePath };
57
+ }
58
+
59
+ if (
60
+ process.argv[1] &&
61
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
62
+ ) {
63
+ try {
64
+ createPromotionRoutingEvidence();
65
+ } catch (error) {
66
+ console.error(
67
+ `promotion routing evidence failed: ${error instanceof Error ? error.message : String(error)}`,
68
+ );
69
+ process.exitCode = 1;
70
+ }
71
+ }
72
+
73
+ export { createPromotionRoutingEvidence };
@@ -90,6 +90,12 @@ function readJson(filePath, label) {
90
90
  }
91
91
  }
92
92
 
93
+ function writeJson(filePath, value) {
94
+ const target = path.resolve(filePath);
95
+ fs.mkdirSync(path.dirname(target), { recursive: true });
96
+ fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`);
97
+ }
98
+
93
99
  function githubApi(route, { method = "GET", body } = {}) {
94
100
  const args = [
95
101
  "api",
@@ -142,6 +148,42 @@ function readRuleset(repository, rulesetId) {
142
148
  return normalizeGithubRulesetSnapshot(response.data);
143
149
  }
144
150
 
151
+ function readMatchingRulesetEntries(repository, branch) {
152
+ const response = githubApi(
153
+ `repos/${repository}/rulesets?includes_parents=false&per_page=100`,
154
+ );
155
+ const include = `refs/heads/${branch}`;
156
+ return (response.data || [])
157
+ .map((entry) => ({
158
+ id: Number(entry.id),
159
+ ruleset: readRuleset(repository, entry.id),
160
+ }))
161
+ .filter(({ ruleset }) => {
162
+ const refName = ruleset.conditions?.ref_name || {};
163
+ return (
164
+ ruleset.target === "branch" &&
165
+ (refName.include || []).length === 1 &&
166
+ refName.include[0] === include &&
167
+ (refName.exclude || []).length === 0
168
+ );
169
+ })
170
+ .sort((left, right) => left.ruleset.name.localeCompare(right.ruleset.name));
171
+ }
172
+
173
+ function readMatchingRulesets(repository, branch) {
174
+ return readMatchingRulesetEntries(repository, branch).map(
175
+ ({ ruleset }) => ruleset,
176
+ );
177
+ }
178
+
179
+ function rulesetInventory(repository, branch) {
180
+ return {
181
+ repository,
182
+ targetRef: branch,
183
+ matchingRulesets: readMatchingRulesets(repository, branch),
184
+ };
185
+ }
186
+
145
187
  function snapshotCore(repository, branch, protection) {
146
188
  return {
147
189
  schemaVersion: 1,
@@ -200,8 +242,8 @@ function rulesetPlan(args) {
200
242
  ...planCore,
201
243
  planRoot: githubGovernanceDigest(planCore),
202
244
  };
203
- fs.writeFileSync(path.resolve(snapshotOutput), `${JSON.stringify(snapshot, null, 2)}\n`);
204
- fs.writeFileSync(path.resolve(planOutput), `${JSON.stringify(finalPlan, null, 2)}\n`);
245
+ writeJson(snapshotOutput, snapshot);
246
+ writeJson(planOutput, finalPlan);
205
247
  return finalPlan;
206
248
  }
207
249
 
@@ -209,13 +251,45 @@ function rulesetPolicyPlan(args) {
209
251
  const repository = required(flag(args, "repository"), "--repository");
210
252
  const branch = required(flag(args, "branch"), "--branch")
211
253
  .replace(/^refs\/heads\//, "");
212
- const rulesetId = Number(required(flag(args, "ruleset-id"), "--ruleset-id"));
213
- if (!Number.isInteger(rulesetId) || rulesetId <= 0) {
214
- throw new Error("--ruleset-id must be a positive integer");
215
- }
254
+ const requestedRulesetId = flag(args, "ruleset-id");
255
+ const rulesetName = required(flag(args, "ruleset-name"), "--ruleset-name");
216
256
  const snapshotOutput = required(flag(args, "snapshot-output"), "--snapshot-output");
217
257
  const planOutput = required(flag(args, "plan-output"), "--plan-output");
218
- const before = readRuleset(repository, rulesetId);
258
+ const matchingEntries = readMatchingRulesetEntries(repository, branch);
259
+ const inventory = {
260
+ repository,
261
+ targetRef: branch,
262
+ matchingRulesets: matchingEntries.map(({ ruleset }) => ruleset),
263
+ };
264
+ let rulesetId = null;
265
+ let before = null;
266
+ if (requestedRulesetId) {
267
+ rulesetId = Number(requestedRulesetId);
268
+ if (!Number.isInteger(rulesetId) || rulesetId <= 0) {
269
+ throw new Error("--ruleset-id must be a positive integer");
270
+ }
271
+ before = readRuleset(repository, rulesetId);
272
+ if (
273
+ !inventory.matchingRulesets.some(
274
+ (ruleset) =>
275
+ githubGovernanceDigest(ruleset) === githubGovernanceDigest(before),
276
+ )
277
+ ) {
278
+ throw new Error("--ruleset-id does not target the exact requested branch");
279
+ }
280
+ } else if (matchingEntries.length === 1) {
281
+ before = matchingEntries[0].ruleset;
282
+ rulesetId = matchingEntries[0].id;
283
+ if (!Number.isInteger(rulesetId) || rulesetId <= 0) {
284
+ throw new Error("matching ruleset id could not be resolved");
285
+ }
286
+ } else if (inventory.matchingRulesets.length > 1) {
287
+ throw new Error("multiple exact-target rulesets are ambiguous");
288
+ } else if (!hasFlag(args, "create-if-missing")) {
289
+ throw new Error(
290
+ "exact-target ruleset is absent; pass --create-if-missing to plan creation",
291
+ );
292
+ }
219
293
  const targetPolicy = resolveGithubGovernanceTargetPolicy({
220
294
  repository,
221
295
  targetRef: branch,
@@ -225,6 +299,7 @@ function rulesetPolicyPlan(args) {
225
299
  contract: "kungfu-buildchain-github-governance-ruleset-rollback-snapshot",
226
300
  repository,
227
301
  targetRef: branch,
302
+ rulesetExists: Boolean(before),
228
303
  rulesetId,
229
304
  ruleset: before,
230
305
  };
@@ -236,13 +311,16 @@ function rulesetPolicyPlan(args) {
236
311
  repository,
237
312
  targetRef: branch,
238
313
  rulesetId,
239
- inventory: before,
314
+ rulesetName,
315
+ inventory,
240
316
  rollbackSnapshot: before,
241
317
  desiredProtection: {
242
318
  strictRequiredChecks: targetPolicy.strictRequiredChecks,
243
319
  requiredCheckBindings: targetPolicy.requiredCheckBindings,
244
320
  requiredApprovals: targetPolicy.requiredApprovals,
245
321
  rulesetBypassActors: [],
322
+ blockDeletions: hasFlag(args, "block-deletions"),
323
+ blockNonFastForward: hasFlag(args, "block-non-fast-forward"),
246
324
  },
247
325
  });
248
326
  const boundCore = {
@@ -255,8 +333,8 @@ function rulesetPolicyPlan(args) {
255
333
  ...planCore,
256
334
  planRoot: githubGovernanceDigest(planCore),
257
335
  };
258
- fs.writeFileSync(path.resolve(snapshotOutput), `${JSON.stringify(snapshot, null, 2)}\n`);
259
- fs.writeFileSync(path.resolve(planOutput), `${JSON.stringify(finalPlan, null, 2)}\n`);
336
+ writeJson(snapshotOutput, snapshot);
337
+ writeJson(planOutput, finalPlan);
260
338
  return finalPlan;
261
339
  }
262
340
 
@@ -276,20 +354,29 @@ function rulesetApply(args) {
276
354
  snapshotRoot !== githubGovernanceDigest(snapshotCore)) {
277
355
  throw new Error("ruleset rollback snapshot root mismatch");
278
356
  }
279
- const current = readRuleset(rollout.repository, rollout.rulesetId);
280
- if (githubGovernanceDigest(current) !== rollout.inventoryRoot) {
357
+ const currentInventory = rulesetInventory(
358
+ rollout.repository,
359
+ rollout.targetRef,
360
+ );
361
+ if (githubGovernanceDigest(currentInventory) !== rollout.inventoryRoot) {
281
362
  throw new Error("live GitHub ruleset drifted after planning; apply stopped");
282
363
  }
283
- githubApi(rollout.operations[0].endpoint, {
364
+ const result = githubApi(rollout.operations[0].endpoint, {
284
365
  method: rollout.operations[0].method,
285
366
  body: rollout.operations[0].body,
286
367
  });
287
- const after = readRuleset(rollout.repository, rollout.rulesetId);
368
+ const rulesetId =
369
+ rollout.rulesetId ||
370
+ Number(result.data?.id || 0);
371
+ if (!Number.isInteger(rulesetId) || rulesetId <= 0) {
372
+ throw new Error("GitHub did not return the created ruleset id");
373
+ }
374
+ const after = readRuleset(rollout.repository, rulesetId);
288
375
  const afterRoot = githubGovernanceDigest(after);
289
376
  if (afterRoot !== rollout.expectedObservation.rulesetRoot) {
290
377
  throw new Error("post-change GitHub ruleset read-back does not match the rollout plan");
291
378
  }
292
- return {
379
+ const receiptCore = {
293
380
  schemaVersion: 1,
294
381
  contract: "kungfu-buildchain-github-governance-ruleset-rollout-receipt",
295
382
  status: "applied",
@@ -297,7 +384,21 @@ function rulesetApply(args) {
297
384
  snapshotRoot: rollout.snapshotRoot,
298
385
  afterRoot,
299
386
  repository: rollout.repository,
300
- rulesetId: rollout.rulesetId,
387
+ targetRef: rollout.targetRef,
388
+ rulesetId,
389
+ rollback:
390
+ rollout.action === "create"
391
+ ? {
392
+ method: "DELETE",
393
+ endpoint: rulesetEndpoint(rollout.repository, rulesetId),
394
+ body: null,
395
+ preconditionRoot: afterRoot,
396
+ }
397
+ : rollout.rollback[0],
398
+ };
399
+ return {
400
+ ...receiptCore,
401
+ receiptRoot: githubGovernanceDigest(receiptCore),
301
402
  };
302
403
  }
303
404
 
@@ -311,14 +412,46 @@ function rulesetRollback(args) {
311
412
  if (rollout.snapshotRoot !== confirmed) {
312
413
  throw new Error("--confirm-rollback-root does not match the frozen ruleset snapshot");
313
414
  }
314
- const operation = rollout.rollback[0];
415
+ let rulesetId = rollout.rulesetId;
416
+ let operation = rollout.rollback[0];
417
+ if (operation.requiresApplyReceipt) {
418
+ const receipt = readJson(
419
+ required(flag(args, "apply-receipt"), "--apply-receipt"),
420
+ "ruleset apply receipt",
421
+ );
422
+ const { receiptRoot, ...receiptCore } = receipt;
423
+ if (
424
+ receiptRoot !== githubGovernanceDigest(receiptCore) ||
425
+ receipt.planRoot !== rollout.planRoot ||
426
+ receipt.repository !== rollout.repository ||
427
+ receipt.targetRef !== rollout.targetRef
428
+ ) {
429
+ throw new Error("ruleset apply receipt does not match the rollout plan");
430
+ }
431
+ rulesetId = Number(receipt.rulesetId);
432
+ operation = receipt.rollback;
433
+ const current = readRuleset(rollout.repository, rulesetId);
434
+ if (githubGovernanceDigest(current) !== operation.preconditionRoot) {
435
+ throw new Error("created ruleset drifted after apply; rollback stopped");
436
+ }
437
+ }
315
438
  githubApi(operation.endpoint, {
316
439
  method: operation.method,
317
440
  body: operation.body,
318
441
  });
319
- const restored = readRuleset(rollout.repository, rollout.rulesetId);
320
- if (githubGovernanceDigest(restored) !== operation.preconditionRoot) {
321
- throw new Error("ruleset rollback read-back does not match the frozen snapshot");
442
+ if (operation.method === "DELETE") {
443
+ const remaining = readMatchingRulesets(
444
+ rollout.repository,
445
+ rollout.targetRef,
446
+ );
447
+ if (remaining.length !== 0) {
448
+ throw new Error("created ruleset remains after rollback");
449
+ }
450
+ } else {
451
+ const restored = readRuleset(rollout.repository, rulesetId);
452
+ if (githubGovernanceDigest(restored) !== operation.preconditionRoot) {
453
+ throw new Error("ruleset rollback read-back does not match the frozen snapshot");
454
+ }
322
455
  }
323
456
  return {
324
457
  schemaVersion: 1,
@@ -327,7 +460,7 @@ function rulesetRollback(args) {
327
460
  planRoot: rollout.planRoot,
328
461
  snapshotRoot: rollout.snapshotRoot,
329
462
  repository: rollout.repository,
330
- rulesetId: rollout.rulesetId,
463
+ rulesetId,
331
464
  };
332
465
  }
333
466
 
@@ -368,8 +501,8 @@ function protectionPolicyPlan(args) {
368
501
  ...boundCore,
369
502
  planRoot: githubGovernanceDigest(boundCore),
370
503
  };
371
- fs.writeFileSync(path.resolve(snapshotOutput), `${JSON.stringify(snapshotWithRoot, null, 2)}\n`);
372
- fs.writeFileSync(path.resolve(planOutput), `${JSON.stringify(finalPlan, null, 2)}\n`);
504
+ writeJson(snapshotOutput, snapshotWithRoot);
505
+ writeJson(planOutput, finalPlan);
373
506
  return finalPlan;
374
507
  }
375
508
 
@@ -419,8 +552,8 @@ function plan(args) {
419
552
  ...boundCore,
420
553
  planRoot: githubGovernanceDigest(boundCore),
421
554
  };
422
- fs.writeFileSync(path.resolve(snapshotOutput), `${JSON.stringify(snapshotWithRoot, null, 2)}\n`);
423
- fs.writeFileSync(path.resolve(planOutput), `${JSON.stringify(finalPlan, null, 2)}\n`);
555
+ writeJson(snapshotOutput, snapshotWithRoot);
556
+ writeJson(planOutput, finalPlan);
424
557
  return finalPlan;
425
558
  }
426
559
 
@@ -114,10 +114,24 @@ export function prepareGateExecutionFiles(files) {
114
114
  for (const file of files) fs.rmSync(file, { force: true });
115
115
  }
116
116
 
117
+ export function commandSpawnOptions({
118
+ cwd,
119
+ env = process.env,
120
+ streamOutput = false,
121
+ } = {}) {
122
+ if (streamOutput) return { cwd, env, stdio: "inherit" };
123
+ return {
124
+ cwd,
125
+ env,
126
+ encoding: "utf8",
127
+ maxBuffer: 16 * 1024 * 1024,
128
+ };
129
+ }
130
+
117
131
  function runArgv(
118
132
  argv,
119
133
  args,
120
- { cwd, env = process.env, allowFailure = false } = {},
134
+ { cwd, env = process.env, allowFailure = false, streamOutput = false } = {},
121
135
  ) {
122
136
  const command = argv[0];
123
137
  const commandArgs = [...argv.slice(1), ...args];
@@ -126,25 +140,12 @@ function runArgv(
126
140
  const batchInvocation = windowsBatch
127
141
  ? windowsBatchInvocation(command, commandArgs, { cwd })
128
142
  : null;
143
+ const spawnOptions = commandSpawnOptions({ cwd, env, streamOutput });
129
144
  const result = windowsBatch
130
- ? spawnSync(
131
- batchInvocation.command,
132
- batchInvocation.args,
133
- {
134
- cwd,
135
- env,
136
- encoding: "utf8",
137
- maxBuffer: 16 * 1024 * 1024,
138
- },
139
- )
140
- : spawnSync(command, commandArgs, {
141
- cwd,
142
- env,
143
- encoding: "utf8",
144
- maxBuffer: 16 * 1024 * 1024,
145
- });
146
- if (result.stdout) process.stdout.write(result.stdout);
147
- if (result.stderr) process.stderr.write(result.stderr);
145
+ ? spawnSync(batchInvocation.command, batchInvocation.args, spawnOptions)
146
+ : spawnSync(command, commandArgs, spawnOptions);
147
+ if (!streamOutput && result.stdout) process.stdout.write(result.stdout);
148
+ if (!streamOutput && result.stderr) process.stderr.write(result.stderr);
148
149
  if (result.error) throw result.error;
149
150
  const status = result.status ?? 1;
150
151
  if (!allowFailure && status !== 0)
@@ -257,7 +258,12 @@ function runMode() {
257
258
  ],
258
259
  registry,
259
260
  );
260
- const runResult = runArgv(argv, runArgs, { cwd, env, allowFailure: true });
261
+ const runResult = runArgv(argv, runArgs, {
262
+ cwd,
263
+ env,
264
+ allowFailure: true,
265
+ streamOutput: true,
266
+ });
261
267
  let receipt = fs.existsSync(receiptPath)
262
268
  ? readJson(receiptPath, "Shifu gate receipt")
263
269
  : null;