release-skill 0.1.1 → 0.1.3

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 (60) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/CHANGELOG.md +60 -0
  5. package/INSTALL.md +179 -5
  6. package/INSTALL.zh-CN.md +320 -0
  7. package/README.md +347 -67
  8. package/README.zh-CN.md +318 -59
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/skills/release-help/SKILL.md +7 -4
  12. package/adapters/claude/skills/release-prepare/SKILL.md +11 -1
  13. package/adapters/claude/skills/release-publish/SKILL.md +6 -3
  14. package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
  15. package/adapters/claude/skills/release-setup/SKILL.md +111 -0
  16. package/adapters/claude/skills/release-verify/SKILL.md +5 -2
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/skills/release-help/SKILL.md +7 -4
  19. package/adapters/codex/skills/release-prepare/SKILL.md +11 -1
  20. package/adapters/codex/skills/release-publish/SKILL.md +6 -3
  21. package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
  22. package/adapters/codex/skills/release-setup/SKILL.md +111 -0
  23. package/adapters/codex/skills/release-verify/SKILL.md +5 -2
  24. package/bin/release-skill.mjs +65 -9
  25. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  26. package/native/safe-write/prebuilds.json +22 -2
  27. package/native/safe-write/src/safe_write.cc +11 -2
  28. package/package.json +3 -1
  29. package/references/02-project-config.md +54 -3
  30. package/references/05-evidence-and-errors.md +6 -2
  31. package/schemas/release-plan.schema.json +550 -65
  32. package/schemas/release-project.schema.json +398 -29
  33. package/schemas/release-run.schema.json +165 -18
  34. package/skills/release-help/SKILL.md +7 -4
  35. package/skills/release-prepare/SKILL.md +11 -1
  36. package/skills/release-publish/SKILL.md +6 -3
  37. package/skills/release-reconcile/SKILL.md +1 -1
  38. package/skills/release-setup/SKILL.md +111 -0
  39. package/skills/release-verify/SKILL.md +5 -2
  40. package/skills-src/release-help/SKILL.md +7 -4
  41. package/skills-src/release-prepare/SKILL.md +11 -1
  42. package/skills-src/release-publish/SKILL.md +6 -3
  43. package/skills-src/release-reconcile/SKILL.md +1 -1
  44. package/skills-src/release-setup/SKILL.md +111 -0
  45. package/skills-src/release-verify/SKILL.md +5 -2
  46. package/src/adapters/contract.mjs +3 -0
  47. package/src/adapters/git-github.mjs +84 -2
  48. package/src/adapters/plugin-marketplace.mjs +65 -21
  49. package/src/adapters/push-snapshot.mjs +84 -17
  50. package/src/commands/prepare.mjs +223 -20
  51. package/src/commands/publish.mjs +45 -0
  52. package/src/commands/reconcile.mjs +152 -0
  53. package/src/commands/setup.mjs +886 -0
  54. package/src/commands/verify.mjs +122 -26
  55. package/src/core/config.mjs +34 -0
  56. package/src/core/errors.mjs +4 -0
  57. package/src/core/plan.mjs +123 -0
  58. package/src/core/previous-public-baseline.mjs +21 -1
  59. package/src/core/verification-gates.mjs +451 -0
  60. package/src/snapshot/frozen.mjs +89 -5
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { readFile, writeFile, mkdtemp, rm, mkdir, lstat, realpath } from 'node:fs/promises';
16
- import { join, relative, isAbsolute, resolve } from 'node:path';
16
+ import { dirname, join, relative, isAbsolute, resolve } from 'node:path';
17
17
  import { tmpdir } from 'node:os';
18
18
  import { execFile as execFileCb } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
@@ -30,6 +30,7 @@ import {
30
30
  writeRunAtomic,
31
31
  computeRunDigest,
32
32
  resolveDefaultRunDir,
33
+ createProductionRunDir,
33
34
  } from '../core/run.mjs';
34
35
  import {
35
36
  assertImmutableApprovalAuthority,
@@ -48,6 +49,7 @@ import {
48
49
  registryTokenKey,
49
50
  resolveNpmRegistryAuthToken,
50
51
  } from '../adapters/npm.mjs';
52
+ import { runConsumerVerificationGates } from '../core/verification-gates.mjs';
51
53
 
52
54
  // ---------------------------------------------------------------------------
53
55
  // Constants
@@ -60,6 +62,7 @@ import {
60
62
  const ADAPTER_ACTION_TYPE_MAP = {
61
63
  'push-commit': 'git-push',
62
64
  'push-snapshot': 'push-snapshot',
65
+ 'set-default-branch': 'set-default-branch',
63
66
  'create-tag': 'git-tag',
64
67
  'npm-publish': 'npm-publish',
65
68
  'github-release': 'github-release',
@@ -170,10 +173,12 @@ export async function runSmokeTest(plan, root, options = {}) {
170
173
  passed: true,
171
174
  skipped: true,
172
175
  details: { message: 'No npm distributions in plan; smoke test skipped' },
176
+ gateResults: [],
173
177
  };
174
178
  }
175
179
 
176
180
  const results = [];
181
+ const gateResults = [];
177
182
 
178
183
  for (const { package: pkgName, registry, targetVersion, unitId, smokeBin, smokeArgs, smokeExpectedJson } of npmDistributions) {
179
184
  const packageAtVersion = `${pkgName}@${targetVersion}`;
@@ -237,6 +242,17 @@ export async function runSmokeTest(plan, root, options = {}) {
237
242
  };
238
243
  }
239
244
 
245
+ const pkgRoot = join(installDir, 'node_modules', pkgName);
246
+ gateResults.push(...await runConsumerVerificationGates({
247
+ plan,
248
+ unitId,
249
+ distribution: 'npm',
250
+ executionRoot: pkgRoot,
251
+ evidence: options.evidence,
252
+ env: options.gateEnv ?? process.env,
253
+ fixedEnv: { HOME: installDir },
254
+ }));
255
+
240
256
  // If smokeBin is not configured, install + name/version check is sufficient
241
257
  if (!smokeBin) {
242
258
  results.push({
@@ -277,7 +293,6 @@ export async function runSmokeTest(plan, root, options = {}) {
277
293
  }
278
294
 
279
295
  // Verify bin path does not escape the installed package root
280
- const pkgRoot = join(installDir, 'node_modules', pkgName);
281
296
  const binPath = resolve(pkgRoot, binRelative);
282
297
  const relBin = relative(pkgRoot, binPath);
283
298
  const sep = process.platform === 'win32' ? '\\' : '/';
@@ -321,7 +336,17 @@ export async function runSmokeTest(plan, root, options = {}) {
321
336
  const cliArgs = smokeArgs.length > 0 ? smokeArgs : [];
322
337
  let binResult;
323
338
  try {
324
- binResult = await npmExec.runBin(binPath, cliArgs);
339
+ binResult = await npmExec.runBin(binPath, cliArgs, {
340
+ cwd: pkgRoot,
341
+ env: {
342
+ HOME: installDir,
343
+ TMPDIR: installDir,
344
+ TEMP: installDir,
345
+ TMP: installDir,
346
+ PATH: dirname(process.execPath),
347
+ CI: '1',
348
+ },
349
+ });
325
350
  } catch (binErr) {
326
351
  return {
327
352
  passed: false,
@@ -403,6 +428,7 @@ export async function runSmokeTest(plan, root, options = {}) {
403
428
  distributions: results,
404
429
  count: results.length,
405
430
  },
431
+ gateResults,
406
432
  };
407
433
  } finally {
408
434
  await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
@@ -451,10 +477,14 @@ const defaultNpmExecutor = {
451
477
  return { success: true };
452
478
  } catch (err) {
453
479
  return { success: false, error: err.message };
480
+ } finally {
481
+ await rm(userConfig, { force: true }).catch(() => {});
454
482
  }
455
483
  },
456
- async runBin(binPath, args = []) {
484
+ async runBin(binPath, args = [], options = {}) {
457
485
  return execFile(process.execPath, [binPath, ...args], {
486
+ cwd: options.cwd,
487
+ env: options.env,
458
488
  shell: false,
459
489
  encoding: 'utf8',
460
490
  timeout: 30_000,
@@ -491,6 +521,8 @@ export async function verifyRelease(options) {
491
521
  runDir: runDirOpt,
492
522
  clock: clockOpt,
493
523
  npmExecutor,
524
+ verificationGatesAuthorized,
525
+ gateEnv,
494
526
  } = options ?? {};
495
527
 
496
528
  const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
@@ -504,10 +536,39 @@ export async function verifyRelease(options) {
504
536
  );
505
537
  }
506
538
 
539
+ // Load and validate the plan before creating any evidence directory. A
540
+ // production plan grants authority only to a fresh direct child of its
541
+ // sibling .release-skill/runs directory.
542
+ let planRaw;
543
+ try {
544
+ planRaw = await readFile(planPath, 'utf8');
545
+ } catch (err) {
546
+ throw new ReleaseError(
547
+ GATE_FAILED,
548
+ `cannot read release plan: ${err.message}`,
549
+ { planPath, cause: err.code },
550
+ );
551
+ }
552
+
553
+ let plan;
554
+ try {
555
+ plan = JSON.parse(planRaw);
556
+ } catch (err) {
557
+ throw new ReleaseError(
558
+ GATE_FAILED,
559
+ `release plan is not valid JSON: ${err.message}`,
560
+ { planPath },
561
+ );
562
+ }
563
+ validatePlan(plan);
564
+
507
565
  // --- Set up directories ---
508
566
  const runId = `verify-${Date.now()}`;
509
- const runDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'verify', runId);
510
- await mkdir(runDir, { recursive: true });
567
+ const requestedRunDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'verify', runId);
568
+ const runDir = plan.production
569
+ ? await createProductionRunDir(requestedRunDir, planPath)
570
+ : requestedRunDir;
571
+ if (!plan.production) await mkdir(runDir, { recursive: true });
511
572
 
512
573
  const evidence = createEvidenceWriter({ runDir, command: 'verify', clock: clockFn });
513
574
 
@@ -517,30 +578,23 @@ export async function verifyRelease(options) {
517
578
  // =======================================================================
518
579
  await evidence.append({ phase: 'verify', step: 'plan-load', status: 'started' });
519
580
 
520
- let planRaw;
521
- try {
522
- planRaw = await readFile(planPath, 'utf8');
523
- } catch (err) {
524
- throw new ReleaseError(
525
- GATE_FAILED,
526
- `cannot read release plan: ${err.message}`,
527
- { planPath, cause: err.code },
528
- );
529
- }
530
-
531
- let plan;
532
- try {
533
- plan = JSON.parse(planRaw);
534
- } catch (err) {
581
+ const consumerGates = (plan.verificationGates ?? []).filter((gate) => gate.phase === 'consumer-verify');
582
+ const configuredSmokeBins = (plan.units ?? []).flatMap((unit) => (
583
+ (unit.distributions ?? [])
584
+ .filter((distribution) => distribution.type === 'npm' && distribution.smokeBin)
585
+ .map((distribution) => ({ unitId: unit.id, smokeBin: distribution.smokeBin }))
586
+ ));
587
+ if ((consumerGates.length > 0 || configuredSmokeBins.length > 0) && verificationGatesAuthorized !== true) {
535
588
  throw new ReleaseError(
536
589
  GATE_FAILED,
537
- `release plan is not valid JSON: ${err.message}`,
538
- { planPath },
590
+ `plan declares ${consumerGates.length} consumer verification gate(s) and ` +
591
+ `${configuredSmokeBins.length} npm CLI smoke process(es). ` +
592
+ 'They execute installed project code without an OS or network sandbox. ' +
593
+ 'To proceed, pass --acknowledge-gate-side-effects (CLI) or verificationGatesAuthorized=true (API).',
594
+ { gateIds: consumerGates.map((gate) => gate.id), configuredSmokeBins },
539
595
  );
540
596
  }
541
597
 
542
- validatePlan(plan);
543
-
544
598
  await evidence.append({ phase: 'verify', step: 'plan-load', status: 'passed' });
545
599
 
546
600
  // =======================================================================
@@ -659,6 +713,7 @@ export async function verifyRelease(options) {
659
713
  await evidence.append({ phase: 'verify', step: 'adapter-verify', status: 'started' });
660
714
 
661
715
  const adapterChecks = [];
716
+ const consumerGateResults = [];
662
717
  const actions = plan.externalActions ?? [];
663
718
  const MARKETPLACE_TYPES = new Set([
664
719
  'claude-marketplace-install',
@@ -770,6 +825,28 @@ export async function verifyRelease(options) {
770
825
  { actionId: action.id, observation: verifyResult.observation },
771
826
  );
772
827
  }
828
+
829
+ const distribution = action.type === 'claude-marketplace-install'
830
+ ? 'claude-plugin'
831
+ : 'codex-plugin';
832
+ const installPath = verifyResult.observation?.installPath;
833
+ consumerGateResults.push(...await runConsumerVerificationGates({
834
+ plan,
835
+ unitId: action.unitId,
836
+ distribution,
837
+ executionRoot: installPath,
838
+ evidence,
839
+ env: gateEnv ?? process.env,
840
+ fixedEnv: action.type === 'claude-marketplace-install'
841
+ ? {
842
+ HOME: resolve(runDir, 'consumers', `claude-${action.parameters.plugin}`),
843
+ CLAUDE_CONFIG_DIR: resolve(runDir, 'consumers', `claude-${action.parameters.plugin}`, '.claude'),
844
+ }
845
+ : {
846
+ HOME: resolve(runDir, 'consumers', `codex-${action.parameters.plugin}`),
847
+ CODEX_HOME: resolve(runDir, 'consumers', `codex-${action.parameters.plugin}`),
848
+ },
849
+ }));
773
850
  } else {
774
851
  // --- Non-marketplace: read-only adapter.verify() ---
775
852
  const context = {
@@ -825,7 +902,12 @@ export async function verifyRelease(options) {
825
902
 
826
903
  let smokeTest;
827
904
  try {
828
- smokeTest = await runSmokeTest(plan, root, { npmExecutor, baseDir: runDir });
905
+ smokeTest = await runSmokeTest(plan, root, {
906
+ npmExecutor,
907
+ baseDir: runDir,
908
+ evidence,
909
+ gateEnv: gateEnv ?? process.env,
910
+ });
829
911
  } catch (err) {
830
912
  smokeTest = { passed: false, details: { error: err.message } };
831
913
  }
@@ -845,6 +927,17 @@ export async function verifyRelease(options) {
845
927
  );
846
928
  }
847
929
 
930
+ consumerGateResults.push(...(smokeTest.gateResults ?? []));
931
+ const expectedGateIds = consumerGates.map((gate) => gate.id).sort();
932
+ const observedGateIds = consumerGateResults.map((result) => result.id).sort();
933
+ if (JSON.stringify(expectedGateIds) !== JSON.stringify(observedGateIds)) {
934
+ throw new ReleaseError(
935
+ POST_PUBLISH_VERIFY_FAILED,
936
+ 'consumer verification gate execution set does not match the frozen plan',
937
+ { expectedGateIds, observedGateIds },
938
+ );
939
+ }
940
+
848
941
  // =======================================================================
849
942
  // All verifications passed — write verify run
850
943
  // =======================================================================
@@ -875,6 +968,7 @@ export async function verifyRelease(options) {
875
968
  status: check?.status === 'SKIPPED' ? 'skipped' : 'succeeded',
876
969
  };
877
970
  }),
971
+ gateResults: consumerGateResults,
878
972
  startedAt: clockFn(),
879
973
  finishedAt: clockFn(),
880
974
  };
@@ -888,6 +982,7 @@ export async function verifyRelease(options) {
888
982
  runDigest: persistedVerifyRun.runDigest,
889
983
  adapterCheckCount: adapterChecks.length,
890
984
  smokeTestPassed: true,
985
+ consumerGateCount: consumerGateResults.length,
891
986
  completedAt: clockFn(),
892
987
  });
893
988
 
@@ -896,6 +991,7 @@ export async function verifyRelease(options) {
896
991
  status: VERIFIED,
897
992
  adapterChecks,
898
993
  smokeTest,
994
+ gateResults: consumerGateResults,
899
995
  };
900
996
  } catch (err) {
901
997
  await evidence.append({
@@ -437,6 +437,40 @@ export async function loadProjectConfig({ root, configPath } = {}) {
437
437
  }
438
438
  }
439
439
 
440
+ // Verification gates are deliberately cross-referenced here instead of
441
+ // inferred at execution time. A gate must name one existing unit and, for
442
+ // consumer verification, one distribution that the unit actually ships.
443
+ const unitsById = new Map(config.releaseUnits.map((unit) => [unit.id, unit]));
444
+ const gateIds = new Set();
445
+ for (const gate of config.verificationGates ?? []) {
446
+ if (gateIds.has(gate.id)) {
447
+ throw new ReleaseError(
448
+ CONFIG_INVALID,
449
+ `duplicate verification gate id: "${gate.id}"`,
450
+ { gateId: gate.id },
451
+ );
452
+ }
453
+ gateIds.add(gate.id);
454
+ const unit = unitsById.get(gate.scope.unit);
455
+ if (!unit) {
456
+ throw new ReleaseError(
457
+ CONFIG_INVALID,
458
+ `verification gate "${gate.id}" references unknown unit "${gate.scope.unit}"`,
459
+ { gateId: gate.id, unitId: gate.scope.unit },
460
+ );
461
+ }
462
+ if (
463
+ gate.phase === 'consumer-verify' &&
464
+ !(unit.distributions ?? []).some((distribution) => distribution.type === gate.scope.distribution)
465
+ ) {
466
+ throw new ReleaseError(
467
+ CONFIG_INVALID,
468
+ `verification gate "${gate.id}" references undeclared distribution "${gate.scope.distribution}"`,
469
+ { gateId: gate.id, unitId: gate.scope.unit, distribution: gate.scope.distribution },
470
+ );
471
+ }
472
+ }
473
+
440
474
  // --- Compute deterministic digest ---
441
475
  const configDigest = sha256Hex(canonicalJson(config));
442
476
 
@@ -33,6 +33,8 @@ const EXIT_CODE_MAP = Object.freeze({
33
33
  SENSITIVE_CONFLICT: 37,
34
34
  TRANSACTION_INCOMPLETE: 38,
35
35
  SAFE_WRITE_UNAVAILABLE: 39,
36
+ SETUP_DIGEST_MISMATCH: 40,
37
+ CONFIG_EXISTS: 41,
36
38
  });
37
39
 
38
40
  // ---- Error code constants ----
@@ -67,6 +69,8 @@ export const PLAN_STALE = 'PLAN_STALE';
67
69
  export const SENSITIVE_CONFLICT = 'SENSITIVE_CONFLICT';
68
70
  export const TRANSACTION_INCOMPLETE = 'TRANSACTION_INCOMPLETE';
69
71
  export const SAFE_WRITE_UNAVAILABLE = 'SAFE_WRITE_UNAVAILABLE';
72
+ export const SETUP_DIGEST_MISMATCH = 'SETUP_DIGEST_MISMATCH';
73
+ export const CONFIG_EXISTS = 'CONFIG_EXISTS';
70
74
 
71
75
  /**
72
76
  * Typed error for release-skill operations.
package/src/core/plan.mjs CHANGED
@@ -132,6 +132,31 @@ export function validatePlan(plan) {
132
132
  { validationErrors: errors },
133
133
  );
134
134
  }
135
+
136
+ const unitsById = new Map((plan.units ?? []).map((unit) => [unit.id, unit]));
137
+ const gateIds = new Set();
138
+ for (const gate of plan.verificationGates ?? []) {
139
+ if (gateIds.has(gate.id)) {
140
+ throw new ReleaseError(GATE_FAILED, `duplicate verification gate id: "${gate.id}"`);
141
+ }
142
+ gateIds.add(gate.id);
143
+ const unit = unitsById.get(gate.scope.unit);
144
+ if (!unit) {
145
+ throw new ReleaseError(
146
+ GATE_FAILED,
147
+ `verification gate "${gate.id}" references unknown unit "${gate.scope.unit}"`,
148
+ );
149
+ }
150
+ if (
151
+ gate.phase === 'consumer-verify' &&
152
+ !(unit.distributions ?? []).some((distribution) => distribution.type === gate.scope.distribution)
153
+ ) {
154
+ throw new ReleaseError(
155
+ GATE_FAILED,
156
+ `verification gate "${gate.id}" references undeclared distribution "${gate.scope.distribution}"`,
157
+ );
158
+ }
159
+ }
135
160
  }
136
161
 
137
162
  /**
@@ -260,6 +285,7 @@ const EXPECTED_ADAPTER = {
260
285
  'npm-publish': 'npm',
261
286
  'claude-marketplace-install': 'plugin-marketplace',
262
287
  'codex-marketplace-install': 'plugin-marketplace',
288
+ 'set-default-branch': 'git-github',
263
289
  };
264
290
 
265
291
  /** Required action types for every unit. */
@@ -317,6 +343,7 @@ export function validatePlanActionCompleteness(plan) {
317
343
  const production = plan.production?.mode === 'github-npm-v1';
318
344
  const frozen = unit.frozenSnapshot;
319
345
  const productionConfig = unit.productionConfig ?? {};
346
+ const branchStrategy = productionConfig.branchStrategy;
320
347
  const expectedTag = tagTemplate
321
348
  ? tagTemplate.replace('{version}', targetVersion ?? '')
322
349
  : null;
@@ -346,6 +373,9 @@ export function validatePlanActionCompleteness(plan) {
346
373
  failures.push(`unit "${unitId}" is missing frozenSnapshot for production publish`);
347
374
  }
348
375
  if (production && frozen) {
376
+ if (!['create-release-branch', 'advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
377
+ failures.push(`unit "${unitId}" productionConfig.branchStrategy is missing or invalid`);
378
+ }
349
379
  const expectedBranch = (productionConfig.branchTemplate ?? 'release/{tag}')
350
380
  .replaceAll('{tag}', expectedTag ?? '')
351
381
  .replaceAll('{version}', targetVersion ?? '')
@@ -353,6 +383,32 @@ export function validatePlanActionCompleteness(plan) {
353
383
  if (frozen.branch !== expectedBranch) {
354
384
  failures.push(`unit "${unitId}" frozen branch does not match productionConfig.branchTemplate`);
355
385
  }
386
+ if (frozen.branchStrategy !== branchStrategy) {
387
+ failures.push(`unit "${unitId}" frozenSnapshot.branchStrategy does not match productionConfig.branchStrategy`);
388
+ }
389
+ if (['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
390
+ if (unit.previousPublicBaseline?.mode !== 'bound') {
391
+ failures.push(`unit "${unitId}" branch strategy "${branchStrategy}" requires a bound previous public baseline`);
392
+ }
393
+ if (!frozen.parentCommit || frozen.parentCommit !== unit.previousPublicBaseline?.commit) {
394
+ failures.push(`unit "${unitId}" frozenSnapshot.parentCommit does not match previous public baseline commit`);
395
+ }
396
+ } else if (frozen.parentCommit) {
397
+ failures.push(`unit "${unitId}" create-release-branch must not freeze a parentCommit`);
398
+ }
399
+ if (
400
+ branchStrategy === 'advance-existing-branch' &&
401
+ unit.previousPublicBaseline?.ref !== `refs/heads/${expectedBranch}`
402
+ ) {
403
+ failures.push(`unit "${unitId}" advance-existing-branch baseline ref does not match the target branch`);
404
+ }
405
+ if (branchStrategy === 'initialize-default-branch') {
406
+ if (productionConfig.setAsDefaultBranch !== true || !productionConfig.expectedCurrentDefaultBranch) {
407
+ failures.push(`unit "${unitId}" initialize-default-branch requires explicit default branch settings`);
408
+ }
409
+ } else if (productionConfig.setAsDefaultBranch === true) {
410
+ failures.push(`unit "${unitId}" setAsDefaultBranch is only valid for initialize-default-branch`);
411
+ }
356
412
  }
357
413
 
358
414
  // Required actions for this unit (always required)
@@ -411,10 +467,19 @@ export function validatePlanActionCompleteness(plan) {
411
467
  _checkRequired(action, 'parameters.branch', action.parameters?.branch, frozen?.branch, unitId, failures);
412
468
  _checkRequired(action, 'parameters.commit', action.parameters?.commit, frozen?.commit, unitId, failures);
413
469
  _checkRequired(action, 'parameters.tree', action.parameters?.tree, frozen?.tree, unitId, failures);
470
+ _checkRequired(action, 'parameters.branchStrategy', action.parameters?.branchStrategy, branchStrategy, unitId, failures);
414
471
  _checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
415
472
  _checkRequired(action, 'expected.commit', action.expected?.commit, frozen?.commit, unitId, failures);
416
473
  _checkRequired(action, 'expected.tree', action.expected?.tree, frozen?.tree, unitId, failures);
417
474
  _checkRequired(action, 'expected.manifestDigest', action.expected?.manifestDigest, frozen?.manifestDigest, unitId, failures);
475
+ if (['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
476
+ _checkRequired(action, 'parameters.parentCommit', action.parameters?.parentCommit, frozen?.parentCommit, unitId, failures);
477
+ }
478
+ if (branchStrategy === 'advance-existing-branch') {
479
+ _checkRequired(action, 'parameters.expectedBaselineCommit', action.parameters?.expectedBaselineCommit, frozen?.parentCommit, unitId, failures);
480
+ } else if (action.parameters?.expectedBaselineCommit !== undefined) {
481
+ failures.push(`unit "${unitId}", action "${action.id}": unexpected parameters.expectedBaselineCommit`);
482
+ }
418
483
  } else {
419
484
  _checkRequired(action, 'expected.tag', action.expected?.tag, expectedTag, unitId, failures);
420
485
  }
@@ -692,6 +757,64 @@ export function validatePlanActionCompleteness(plan) {
692
757
  }
693
758
  }
694
759
  }
760
+
761
+ // set-default-branch: only required when productionConfig.setAsDefaultBranch is true
762
+ if (branchStrategy === 'initialize-default-branch') {
763
+ const oldBranch = productionConfig.expectedCurrentDefaultBranch;
764
+ const newBranch = frozen?.branch;
765
+ expectedCount++;
766
+ const expectedActionId = `set-default-branch-${unitId}`;
767
+ const branchActions = actions.filter(
768
+ (a) => a.unitId === unitId && a.type === 'set-default-branch',
769
+ );
770
+
771
+ if (branchActions.length === 0) {
772
+ failures.push(
773
+ `unit "${unitId}": productionConfig.setAsDefaultBranch is true but "set-default-branch" action is missing`,
774
+ );
775
+ } else if (branchActions.length > 1) {
776
+ failures.push(
777
+ `unit "${unitId}": duplicate set-default-branch actions (${branchActions.length} found, expected 1)`,
778
+ );
779
+ } else {
780
+ const action = branchActions[0];
781
+
782
+ if (action.id !== expectedActionId) {
783
+ failures.push(
784
+ `unit "${unitId}", action "${action.id}": id is "${action.id}", expected "${expectedActionId}"`,
785
+ );
786
+ }
787
+ if (action.unitId !== unitId) {
788
+ failures.push(
789
+ `unit "${unitId}", action "${action.id}": unitId is "${action.unitId}", expected "${unitId}"`,
790
+ );
791
+ }
792
+ if (action.adapter !== 'git-github') {
793
+ failures.push(
794
+ `unit "${unitId}", action "${action.id}": adapter is "${action.adapter}", expected "git-github"`,
795
+ );
796
+ }
797
+ if (action.status !== 'PENDING') {
798
+ failures.push(
799
+ `unit "${unitId}", action "${action.id}": status is "${action.status ?? '(missing)'}", expected "PENDING"`,
800
+ );
801
+ }
802
+
803
+ // Required parameters
804
+ _checkRequired(action, 'parameters.repo', action.parameters?.repo, publicRepo, unitId, failures);
805
+ _checkRequired(action, 'parameters.oldBranch', action.parameters?.oldBranch, oldBranch, unitId, failures);
806
+ _checkRequired(action, 'parameters.newBranch', action.parameters?.newBranch, newBranch, unitId, failures);
807
+ _checkRequired(action, 'parameters.expectedNewBranchCommit', action.parameters?.expectedNewBranchCommit, frozen?.commit, unitId, failures);
808
+ _checkRequired(action, 'parameters.githubHost', action.parameters?.githubHost, productionConfig.githubHost ?? 'github.com', unitId, failures);
809
+
810
+ // Required expected
811
+ _checkRequired(action, 'expected.defaultBranch', action.expected?.defaultBranch, newBranch, unitId, failures);
812
+ _checkRequired(action, 'expected.newBranchCommit', action.expected?.newBranchCommit, frozen?.commit, unitId, failures);
813
+ if (Object.keys(action.expected ?? {}).some((key) => !['defaultBranch', 'newBranchCommit'].includes(key))) {
814
+ failures.push(`unit "${unitId}", action "${action.id}": expected may only contain defaultBranch and newBranchCommit`);
815
+ }
816
+ }
817
+ }
695
818
  }
696
819
 
697
820
  // --- Strict count equality: actual must exactly match expected ---
@@ -88,7 +88,7 @@ export async function observePreviousPublicBaseline({ baseline, observeFn, evide
88
88
  * Evidence collector for audit trail.
89
89
  * @returns {Promise<{ consistent: true } | { consistent: false, error: string, detail?: Record<string, unknown> }>}
90
90
  */
91
- export async function reObservePreviousPublicBaseline({ baseline, observeFn, evidence }) {
91
+ export async function reObservePreviousPublicBaseline({ baseline, observeFn, evidence, acceptedSuccessorCommits = [] }) {
92
92
  if (!baseline || typeof baseline !== 'object') {
93
93
  return { consistent: true };
94
94
  }
@@ -114,6 +114,26 @@ export async function reObservePreviousPublicBaseline({ baseline, observeFn, evi
114
114
  return { consistent: true };
115
115
  }
116
116
 
117
+ if (
118
+ result.status === 'drifted' &&
119
+ typeof result.actual === 'string' &&
120
+ acceptedSuccessorCommits.includes(result.actual)
121
+ ) {
122
+ evidence?.append({
123
+ phase: 're-observe-previous-public-baseline',
124
+ status: 'consistent-planned-successor',
125
+ repo,
126
+ ref,
127
+ baselineCommit: commit,
128
+ observedCommit: result.actual,
129
+ });
130
+ return {
131
+ consistent: true,
132
+ observed: { ...result },
133
+ state: 'planned-successor',
134
+ };
135
+ }
136
+
117
137
  // Drifted or unknown -- return soft failure for reconcile to handle
118
138
  return {
119
139
  consistent: false,