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

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.
@@ -1191,20 +1191,38 @@ export function createGithubRulesetGovernanceRolloutPlan({
1191
1191
  repository,
1192
1192
  targetRef,
1193
1193
  rulesetId,
1194
+ rulesetName,
1194
1195
  inventory,
1195
1196
  rollbackSnapshot,
1196
1197
  desiredProtection,
1197
1198
  } = {}) {
1198
1199
  const fullName = requiredString(repository, "repository");
1199
1200
  const branch = normalizedRef(targetRef);
1200
- const id = Number(rulesetId);
1201
- if (!Number.isInteger(id) || id <= 0) {
1202
- throw new Error("ruleset id must be a positive integer");
1201
+ const creating = rulesetId === null || rulesetId === undefined || rulesetId === "";
1202
+ const id = creating ? null : Number(rulesetId);
1203
+ if (!creating && (!Number.isInteger(id) || id <= 0)) {
1204
+ throw new Error("ruleset id must be a positive integer when supplied");
1203
1205
  }
1204
- if (!inventory || !rollbackSnapshot) {
1205
- throw new Error("read-only inventory and frozen rollback snapshot are required");
1206
+ if (!inventory || (!creating && !rollbackSnapshot)) {
1207
+ throw new Error(
1208
+ "read-only inventory and any existing frozen rollback snapshot are required",
1209
+ );
1206
1210
  }
1207
- const before = normalizeGithubRulesetSnapshot(rollbackSnapshot);
1211
+ const before = creating
1212
+ ? {
1213
+ name: requiredString(rulesetName, "ruleset name"),
1214
+ target: "branch",
1215
+ enforcement: "active",
1216
+ bypass_actors: [],
1217
+ conditions: {
1218
+ ref_name: {
1219
+ include: [`refs/heads/${branch}`],
1220
+ exclude: [],
1221
+ },
1222
+ },
1223
+ rules: [],
1224
+ }
1225
+ : normalizeGithubRulesetSnapshot(rollbackSnapshot);
1208
1226
  const exactInclude = before.conditions?.ref_name?.include || [];
1209
1227
  const exactExclude = before.conditions?.ref_name?.exclude || [];
1210
1228
  if (
@@ -1265,28 +1283,53 @@ export function createGithubRulesetGovernanceRolloutPlan({
1265
1283
  });
1266
1284
  if (pullRequestRules.length === 0) desiredRules.push(desiredPullRequestRule);
1267
1285
  if (statusCheckRules.length === 0) desiredRules.push(desiredStatusCheckRule);
1286
+ if (
1287
+ desiredProtection?.blockDeletions === true &&
1288
+ !desiredRules.some((rule) => rule.type === "deletion")
1289
+ ) {
1290
+ desiredRules.push({ type: "deletion" });
1291
+ }
1292
+ if (
1293
+ desiredProtection?.blockNonFastForward === true &&
1294
+ !desiredRules.some((rule) => rule.type === "non_fast_forward")
1295
+ ) {
1296
+ desiredRules.push({ type: "non_fast_forward" });
1297
+ }
1268
1298
  const desired = {
1269
1299
  ...before,
1300
+ name: rulesetName
1301
+ ? requiredString(rulesetName, "ruleset name")
1302
+ : before.name,
1270
1303
  bypass_actors: providerRulesetBypassActors(
1271
1304
  desiredProtection?.rulesetBypassActors,
1272
1305
  ),
1273
1306
  rules: desiredRules,
1274
1307
  };
1275
- const endpoint = `repos/${fullName}/rulesets/${id}`;
1308
+ const endpoint = creating
1309
+ ? `repos/${fullName}/rulesets`
1310
+ : `repos/${fullName}/rulesets/${id}`;
1276
1311
  const core = {
1277
1312
  schemaVersion: 1,
1278
1313
  contract: GITHUB_GOVERNANCE_RULESET_ROLLOUT_CONTRACT,
1279
1314
  repository: fullName,
1280
1315
  targetRef: branch,
1281
1316
  rulesetId: id,
1317
+ rulesetName: desired.name,
1318
+ action: creating ? "create" : "update",
1282
1319
  inventoryRoot: githubGovernanceDigest(inventory),
1283
- rollbackSnapshotRoot: githubGovernanceDigest(before),
1284
- operations: [{ method: "PUT", endpoint, body: desired }],
1320
+ rollbackSnapshotRoot: creating
1321
+ ? githubGovernanceDigest({ rulesetExists: false, targetRef: branch })
1322
+ : githubGovernanceDigest(before),
1323
+ operations: [
1324
+ { method: creating ? "POST" : "PUT", endpoint, body: desired },
1325
+ ],
1285
1326
  impact: [
1286
1327
  "replace ruleset bypass actors with the exact provider-admitted desired set",
1287
1328
  "require fresh Code Owner review and resolved review threads",
1288
1329
  "bind required status checks and strictness to the authoritative target descriptor",
1289
- "preserve unrelated ruleset rules and exact target conditions",
1330
+ creating
1331
+ ? "create one exact-branch active ruleset because no matching ruleset exists"
1332
+ : "preserve unrelated ruleset rules and exact target conditions",
1290
1333
  ],
1291
1334
  expectedObservation: {
1292
1335
  rulesetRoot: githubGovernanceDigest(desired),
@@ -1296,12 +1339,24 @@ export function createGithubRulesetGovernanceRolloutPlan({
1296
1339
  desiredProtection?.strictRequiredChecks === true,
1297
1340
  requiredApprovals,
1298
1341
  },
1299
- rollback: [{
1300
- method: "PUT",
1301
- endpoint,
1302
- body: before,
1303
- preconditionRoot: githubGovernanceDigest(before),
1304
- }],
1342
+ rollback: creating
1343
+ ? [
1344
+ {
1345
+ method: "DELETE",
1346
+ endpoint: `repos/${fullName}/rulesets/{ruleset_id}`,
1347
+ body: null,
1348
+ preconditionRoot: githubGovernanceDigest(desired),
1349
+ requiresApplyReceipt: true,
1350
+ },
1351
+ ]
1352
+ : [
1353
+ {
1354
+ method: "PUT",
1355
+ endpoint,
1356
+ body: before,
1357
+ preconditionRoot: githubGovernanceDigest(before),
1358
+ },
1359
+ ],
1305
1360
  };
1306
1361
  return { ...core, planRoot: githubGovernanceDigest(core) };
1307
1362
  }
@@ -64,6 +64,16 @@ export {
64
64
  verifyPortableDevCachePlan,
65
65
  } from "./portable-dev-cache.js";
66
66
 
67
+ export {
68
+ BUILDCHAIN_CACHE_EVIDENCE_SET_CONTRACT,
69
+ BUILDCHAIN_CACHE_OPERATION_RECEIPT_CONTRACT,
70
+ cacheEvidenceDigest,
71
+ createCacheEvidenceSet,
72
+ createCacheOperationReceipt,
73
+ verifyCacheEvidenceSet,
74
+ verifyCacheOperationReceipt,
75
+ } from "./cache-evidence.js";
76
+
67
77
  export {
68
78
  explainReleaseLineDryRun,
69
79
  formatReleaseLineDryRun,
@@ -164,7 +164,18 @@ function validateScene(value) {
164
164
  for (const key of ["background", "accent"]) {
165
165
  if (value[key] !== undefined) invariant(/^#[0-9a-fA-F]{6}$/.test(value[key]), `scene.${key} is invalid`);
166
166
  }
167
- return value;
167
+ return {
168
+ schema: value.schema,
169
+ id: value.id,
170
+ width: value.width,
171
+ height: value.height,
172
+ fps: value.fps,
173
+ durationMs: value.durationMs,
174
+ title: value.title,
175
+ commandLabel: value.commandLabel ?? "",
176
+ background: (value.background ?? "#10151f").toLowerCase(),
177
+ accent: (value.accent ?? "#67e8a5").toLowerCase(),
178
+ };
168
179
  }
169
180
 
170
181
  function validateProjection(value, scene, transcriptLineCount) {
@@ -190,7 +201,17 @@ function validateProjection(value, scene, transcriptLineCount) {
190
201
  }
191
202
  if (cue.annotation !== undefined) text(cue.annotation, 0, 200, `projection.cues[${index}].annotation`);
192
203
  }
193
- return value;
204
+ return {
205
+ schema: value.schema,
206
+ evidenceClass: value.evidenceClass,
207
+ claimBoundary: value.claimBoundary,
208
+ cues: value.cues.map((cue) => ({
209
+ startMs: cue.startMs,
210
+ endMs: cue.endMs,
211
+ transcriptLines: cue.transcriptLines,
212
+ annotation: cue.annotation ?? "",
213
+ })),
214
+ };
194
215
  }
195
216
 
196
217
  function validateSourceCoordinate(value) {
@@ -427,7 +448,9 @@ function finalizeGate(values) {
427
448
  const sourceCoordinate = validateSourceCoordinate(readJson(sourceCoordinatePath, "source artifact coordinate"));
428
449
  invariant(sourceCoordinate.sourceSha === sourceSha, "source artifact coordinate SHA mismatch");
429
450
  ensureEmptyDirectory(output, "gate bundle");
430
- for (const name of REQUIRED_ADAPTER_FILES) copyFile(path.join(adapterOutput, name), path.join(output, name));
451
+ fs.writeFileSync(path.join(output, "complete-transcript.txt"), normalized.transcript);
452
+ writeJson(path.join(output, "scene.json"), normalized.scene);
453
+ writeJson(path.join(output, "public-projection.json"), normalized.projection);
431
454
  copyFile(sourceCoordinatePath, path.join(output, "source-artifact.json"));
432
455
  copyFile(path.join(diagnostics, "adapter.json"), path.join(output, "adapter.json"));
433
456
  for (const name of listFiles(smokeOutput)) {
@@ -454,9 +477,9 @@ function finalizeGate(values) {
454
477
  smokeManifestRoot: sha256(readRegular(path.join(smokeOutput, "manifest.json"), "smoke manifest")),
455
478
  },
456
479
  qualifiedInputs: {
457
- transcript: sha256(readRegular(path.join(adapterOutput, "complete-transcript.txt"), "transcript")),
458
- projection: sha256(readRegular(path.join(adapterOutput, "public-projection.json"), "projection")),
459
- scene: sha256(readRegular(path.join(adapterOutput, "scene.json"), "scene")),
480
+ transcript: sha256(readRegular(path.join(output, "complete-transcript.txt"), "transcript")),
481
+ projection: sha256(readRegular(path.join(output, "public-projection.json"), "projection")),
482
+ scene: sha256(readRegular(path.join(output, "scene.json"), "scene")),
460
483
  evidenceClass: normalized.projection.evidenceClass,
461
484
  claimBoundary: normalized.projection.claimBoundary,
462
485
  },
@@ -516,6 +516,7 @@ function nodeApiMeta(exportName) {
516
516
  "./homebrew": { group: "distribution-indexes", summary: "Homebrew tap fact collection, Formula rendering, update, and check APIs." },
517
517
  "./build-facts": { group: "observability-diagnostics", summary: "Git source, version, module output, product artifact, and legacy Kungfu build fact APIs." },
518
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." },
519
520
  "./diagnostics": { group: "observability-diagnostics", summary: "Native diagnostics collection, summarization, cache, compiler, and process-sampler APIs." },
520
521
  "./logging": { group: "observability-diagnostics", summary: "Buildchain JSONL logging, span, summary, and verification APIs." },
521
522
  "./portable-dev-cache": { group: "observability-diagnostics", summary: "Portable dependency/compiler cache plan, exact-root verification, and provider receipt APIs." },
@@ -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);
@@ -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