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
@@ -25,6 +25,7 @@ const execFile = promisify(execFileCb);
25
25
  import { loadProjectConfig } from '../core/config.mjs';
26
26
  import { captureBaseline } from '../core/baseline.mjs';
27
27
  import { runHook } from '../core/hooks.mjs';
28
+ import { runSnapshotVerificationGates } from '../core/verification-gates.mjs';
28
29
  import { createEvidenceWriter } from '../core/evidence.mjs';
29
30
  import { computePlanDigest, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
30
31
  import { sha256Hex } from '../core/digest.mjs';
@@ -483,7 +484,37 @@ async function processSnapshots(config, root, evidence, runDir, production = fal
483
484
  return { unitResults, snapshotDigests };
484
485
  }
485
486
 
486
- async function buildProductionAssets(unitResults, resolvedVersions, root, runDir) {
487
+ function resolveProductionBranch(unit, version) {
488
+ const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
489
+ const tag = tagTemplate.replace('{version}', version);
490
+ const branchTemplate = unit.production?.branchTemplate ?? 'release/{tag}';
491
+ return {
492
+ tag,
493
+ branch: branchTemplate
494
+ .replaceAll('{tag}', tag)
495
+ .replaceAll('{version}', version)
496
+ .replaceAll('{unit}', unit.id),
497
+ branchStrategy: unit.production?.branchStrategy ?? 'create-release-branch',
498
+ };
499
+ }
500
+
501
+ function normalizedProductionConfig(unit) {
502
+ return {
503
+ ...(unit.production ?? {}),
504
+ githubHost: unit.production?.githubHost ?? 'github.com',
505
+ branchTemplate: unit.production?.branchTemplate ?? 'release/{tag}',
506
+ branchStrategy: unit.production?.branchStrategy ?? 'create-release-branch',
507
+ };
508
+ }
509
+
510
+ async function buildProductionAssets(
511
+ unitResults,
512
+ resolvedVersions,
513
+ root,
514
+ runDir,
515
+ unitBaselineResults,
516
+ buildGitRepository = buildFrozenGitRepository,
517
+ ) {
487
518
  for (const { unit } of unitResults) {
488
519
  const npmDistribution = (unit.distributions ?? []).find((distribution) => distribution.type === 'npm');
489
520
  if (npmDistribution && !['public', 'restricted'].includes(npmDistribution.access)) {
@@ -497,13 +528,7 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
497
528
  for (let index = 0; index < unitResults.length; index += 1) {
498
529
  const { unit, manifest } = unitResults[index];
499
530
  const version = resolvedVersions[index];
500
- const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
501
- const tag = tagTemplate.replace('{version}', version);
502
- const branchTemplate = unit.production?.branchTemplate ?? 'release/{tag}';
503
- const branch = branchTemplate
504
- .replaceAll('{tag}', tag)
505
- .replaceAll('{version}', version)
506
- .replaceAll('{unit}', unit.id);
531
+ const { tag, branch, branchStrategy } = resolveProductionBranch(unit, version);
507
532
  const snapshotPath = relative(root, manifest.outputDir);
508
533
  const observed = await computeFrozenSnapshot(manifest.outputDir);
509
534
  if (observed.digest !== manifest.snapshotDigest) {
@@ -521,11 +546,21 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
521
546
  const sealed = await computeFrozenSnapshot(manifest.outputDir);
522
547
 
523
548
  const repositoryDir = resolveUnitScopedPath(resolve(runDir, 'git'), unit.id, { suffix: '.git' });
524
- const git = await buildFrozenGitRepository({
549
+ const unitBaseline = unitBaselineResults.get(unit.id);
550
+ const parent = branchStrategy === 'create-release-branch'
551
+ ? undefined
552
+ : {
553
+ githubHost: unitBaseline.githubHost,
554
+ repo: unitBaseline.repo,
555
+ ref: unitBaseline.ref,
556
+ commit: unitBaseline.commit,
557
+ };
558
+ const git = await buildGitRepository({
525
559
  snapshotDir: manifest.outputDir,
526
560
  repositoryDir,
527
561
  version,
528
562
  expectedSnapshotDigest: sealed.digest,
563
+ parent,
529
564
  });
530
565
 
531
566
  let npm = null;
@@ -551,6 +586,8 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
551
586
  gitObjectDir: relative(root, repositoryDir),
552
587
  commit: git.commit,
553
588
  tree: git.tree,
589
+ branchStrategy,
590
+ ...(git.parentCommit ? { parentCommit: git.parentCommit } : {}),
554
591
  branch,
555
592
  tag,
556
593
  npm: npm ? {
@@ -725,6 +762,11 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
725
762
  githubHost: unit.production?.githubHost ?? 'github.com',
726
763
  commit: asset.commit,
727
764
  tree: asset.tree,
765
+ branchStrategy: asset.branchStrategy,
766
+ ...(asset.parentCommit ? { parentCommit: asset.parentCommit } : {}),
767
+ ...(asset.branchStrategy === 'advance-existing-branch'
768
+ ? { expectedBaselineCommit: asset.parentCommit }
769
+ : {}),
728
770
  },
729
771
  expected: {
730
772
  branch: asset.branch,
@@ -735,6 +777,24 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
735
777
  status: 'PENDING',
736
778
  });
737
779
 
780
+ if (asset.branchStrategy === 'initialize-default-branch') {
781
+ actions.push({
782
+ id: `set-default-branch-${unit.id}`,
783
+ type: 'set-default-branch',
784
+ adapter: 'git-github',
785
+ unitId: unit.id,
786
+ parameters: {
787
+ repo: unit.publicRepo,
788
+ githubHost: unit.production?.githubHost ?? 'github.com',
789
+ oldBranch: unit.production.expectedCurrentDefaultBranch,
790
+ newBranch: asset.branch,
791
+ expectedNewBranchCommit: asset.commit,
792
+ },
793
+ expected: { defaultBranch: asset.branch, newBranchCommit: asset.commit },
794
+ status: 'PENDING',
795
+ });
796
+ }
797
+
738
798
  // Create tag
739
799
  actions.push({
740
800
  id: `create-tag-${unit.id}`,
@@ -908,6 +968,8 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
908
968
  * the project config declares hooks. Hooks are user-configured arbitrary
909
969
  * local processes without filesystem/network isolation. Authorization
910
970
  * means the user accepts hook side-effect risks, not that hooks are safe.
971
+ * @param {boolean} [options.verificationGatesAuthorized] - Must be explicitly
972
+ * true when project verification gates are declared.
911
973
  *
912
974
  * @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string }>}
913
975
  *
@@ -922,6 +984,7 @@ export async function prepareRelease(options) {
922
984
  runDir: runDirOpt,
923
985
  clock,
924
986
  hooksAuthorized,
987
+ verificationGatesAuthorized,
925
988
  production = false,
926
989
  observePreviousPublicBaselineFn,
927
990
  } = options ?? {};
@@ -1048,6 +1111,43 @@ export async function prepareRelease(options) {
1048
1111
  });
1049
1112
  }
1050
1113
 
1114
+ const declaredVerificationGates = config.verificationGates ?? [];
1115
+ if (declaredVerificationGates.length > 0) {
1116
+ await evidence.append({
1117
+ phase: 'verification-gate-authorization',
1118
+ status: 'started',
1119
+ gateCount: declaredVerificationGates.length,
1120
+ gates: declaredVerificationGates.map((gate) => ({
1121
+ id: gate.id,
1122
+ phase: gate.phase,
1123
+ unitId: gate.scope.unit,
1124
+ distribution: gate.scope.distribution ?? null,
1125
+ executable: gate.command[0],
1126
+ args: gate.command.slice(1),
1127
+ cwd: gate.cwd,
1128
+ })),
1129
+ });
1130
+ if (verificationGatesAuthorized !== true) {
1131
+ await evidence.append({
1132
+ phase: 'verification-gate-authorization',
1133
+ status: 'denied',
1134
+ gateCount: declaredVerificationGates.length,
1135
+ });
1136
+ throw new ReleaseError(
1137
+ GATE_FAILED,
1138
+ `project declares ${declaredVerificationGates.length} verification gate(s). ` +
1139
+ 'They run local project commands without a network sandbox. ' +
1140
+ 'To proceed, pass --acknowledge-gate-side-effects (CLI) or verificationGatesAuthorized=true (API).',
1141
+ { gateIds: declaredVerificationGates.map((gate) => gate.id) },
1142
+ );
1143
+ }
1144
+ await evidence.append({
1145
+ phase: 'verification-gate-authorization',
1146
+ status: 'authorized',
1147
+ gateCount: declaredVerificationGates.length,
1148
+ });
1149
+ }
1150
+
1051
1151
  // --- Step 3: Run declared hooks ---
1052
1152
  await evidence.append({ phase: 'hooks', status: 'started' });
1053
1153
  await runDeclaredHooks(config, realRoot, evidence);
@@ -1069,6 +1169,12 @@ export async function prepareRelease(options) {
1069
1169
 
1070
1170
  // --- Step 4b: Per-unit previous public baseline observe ---
1071
1171
  const configUnits = config.releaseUnits ?? [];
1172
+ const resolvedVersions = await resolveAllUnitVersions(
1173
+ configUnits,
1174
+ realRoot,
1175
+ version,
1176
+ evidence,
1177
+ );
1072
1178
  const defaultObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
1073
1179
  try {
1074
1180
  const { stdout } = await execFile("git", ["ls-remote", `https://${githubHost}/${repo}.git`, ref], {
@@ -1084,11 +1190,57 @@ export async function prepareRelease(options) {
1084
1190
  }
1085
1191
  };
1086
1192
  const observeFn = options.observePreviousPublicBaselineFn ?? defaultObserveFn;
1193
+ const defaultObserveDefaultBranchFn = async (repo, { githubHost = 'github.com' } = {}) => {
1194
+ try {
1195
+ const { stdout } = await execFile(
1196
+ 'gh',
1197
+ ['api', `repos/${repo}`, '--jq', '.default_branch'],
1198
+ {
1199
+ shell: false,
1200
+ encoding: 'utf8',
1201
+ timeout: 30000,
1202
+ env: { ...process.env, GH_HOST: githubHost },
1203
+ },
1204
+ );
1205
+ return { status: 'observed', defaultBranch: stdout.trim() };
1206
+ } catch (error) {
1207
+ return { status: 'unknown', error: error.message };
1208
+ }
1209
+ };
1210
+ const observeDefaultBranch = options.observeDefaultBranchFn ?? defaultObserveDefaultBranchFn;
1087
1211
  const unitBaselineResults = new Map();
1088
- for (const unit of configUnits) {
1212
+ for (let unitIndex = 0; unitIndex < configUnits.length; unitIndex += 1) {
1213
+ const unit = configUnits[unitIndex];
1089
1214
  const ppbConfig = unit.previousPublicBaseline;
1090
1215
  if (!ppbConfig) continue;
1091
1216
  const productionGithubHost = unit.production?.githubHost ?? 'github.com';
1217
+ const { branch, branchStrategy } = resolveProductionBranch(unit, resolvedVersions[unitIndex]);
1218
+ if (production && ['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
1219
+ if (offline) {
1220
+ throw new ReleaseError(
1221
+ GATE_FAILED,
1222
+ `unit "${unit.id}" branch strategy "${branchStrategy}" requires online production prepare`,
1223
+ { unitId: unit.id, branchStrategy },
1224
+ );
1225
+ }
1226
+ if (ppbConfig.mode !== 'bound') {
1227
+ throw new ReleaseError(
1228
+ GATE_FAILED,
1229
+ `unit "${unit.id}" branch strategy "${branchStrategy}" requires previousPublicBaseline.mode=bound`,
1230
+ { unitId: unit.id, branchStrategy },
1231
+ );
1232
+ }
1233
+ if (
1234
+ branchStrategy === 'advance-existing-branch' &&
1235
+ ppbConfig.ref !== `refs/heads/${branch}`
1236
+ ) {
1237
+ throw new ReleaseError(
1238
+ GATE_FAILED,
1239
+ `unit "${unit.id}" advance-existing-branch baseline ref must equal refs/heads/${branch}`,
1240
+ { unitId: unit.id, expectedRef: `refs/heads/${branch}`, actualRef: ppbConfig.ref },
1241
+ );
1242
+ }
1243
+ }
1092
1244
  const effectivePpbConfig = ppbConfig.mode === 'bound'
1093
1245
  ? { ...ppbConfig, githubHost: productionGithubHost }
1094
1246
  : ppbConfig;
@@ -1214,6 +1366,37 @@ export async function prepareRelease(options) {
1214
1366
  status: "completed",
1215
1367
  consistent: true,
1216
1368
  });
1369
+
1370
+ if (production && branchStrategy === 'initialize-default-branch') {
1371
+ const expectedCurrent = unit.production?.expectedCurrentDefaultBranch;
1372
+ const observedDefault = await observeDefaultBranch(unit.publicRepo, {
1373
+ githubHost: productionGithubHost,
1374
+ });
1375
+ await evidence.append({
1376
+ phase: 'default-branch-observe',
1377
+ unitId: unit.id,
1378
+ status: observedDefault.status,
1379
+ expectedCurrentDefaultBranch: expectedCurrent,
1380
+ observedCurrentDefaultBranch: observedDefault.defaultBranch ?? null,
1381
+ ...(observedDefault.error ? { error: observedDefault.error } : {}),
1382
+ });
1383
+ if (
1384
+ observedDefault.status !== 'observed' ||
1385
+ !observedDefault.defaultBranch ||
1386
+ observedDefault.defaultBranch !== expectedCurrent
1387
+ ) {
1388
+ throw new ReleaseError(
1389
+ GATE_FAILED,
1390
+ `unit "${unit.id}" GitHub default branch does not match expectedCurrentDefaultBranch`,
1391
+ {
1392
+ unitId: unit.id,
1393
+ expectedCurrentDefaultBranch: expectedCurrent,
1394
+ observedCurrentDefaultBranch: observedDefault.defaultBranch ?? null,
1395
+ observationStatus: observedDefault.status,
1396
+ },
1397
+ );
1398
+ }
1399
+ }
1217
1400
  }
1218
1401
 
1219
1402
  // --- Step 5: Build snapshots, scan, and evaluate README ---
@@ -1221,6 +1404,22 @@ export async function prepareRelease(options) {
1221
1404
  config, realRoot, evidence, runDir, production,
1222
1405
  );
1223
1406
 
1407
+ // Snapshot gates always run on disposable writable copies. The public
1408
+ // snapshot authority is re-digested after every gate and is never exposed
1409
+ // as the gate working directory.
1410
+ const snapshotGateResults = await runSnapshotVerificationGates({
1411
+ gates: declaredVerificationGates,
1412
+ unitResults,
1413
+ runDir,
1414
+ evidence,
1415
+ env: options.gateEnv ?? process.env,
1416
+ });
1417
+ await evidence.append({
1418
+ phase: 'snapshot-verify',
1419
+ status: 'completed',
1420
+ gateCount: snapshotGateResults.length,
1421
+ });
1422
+
1224
1423
  // --- Step 6: Remote uniqueness (deferred to publish preflight) ---
1225
1424
  // Prepare only observes the previous public baseline (already done above).
1226
1425
  // Remote uniqueness checks (tag, GitHub Release, npm version) are deferred
@@ -1244,16 +1443,15 @@ export async function prepareRelease(options) {
1244
1443
  // --- Step 7: Build plan object ---
1245
1444
  await evidence.append({ phase: 'plan-assembly', status: 'started' });
1246
1445
 
1247
- // Resolve versions for all units
1248
- const resolvedVersions = await resolveAllUnitVersions(
1249
- config.releaseUnits ?? [],
1250
- realRoot,
1251
- version,
1252
- evidence,
1253
- );
1254
-
1255
1446
  const productionAssets = production
1256
- ? await buildProductionAssets(unitResults, resolvedVersions, realRoot, runDir)
1447
+ ? await buildProductionAssets(
1448
+ unitResults,
1449
+ resolvedVersions,
1450
+ realRoot,
1451
+ runDir,
1452
+ unitBaselineResults,
1453
+ options.buildFrozenGitRepositoryFn ?? buildFrozenGitRepository,
1454
+ )
1257
1455
  : null;
1258
1456
 
1259
1457
  const units = unitResults.map(({ unit, manifest }, idx) => {
@@ -1267,14 +1465,18 @@ export async function prepareRelease(options) {
1267
1465
  tagTemplate: unit.version?.tagTemplate,
1268
1466
  snapshotDigest: snapshotDigests[idx],
1269
1467
  ...(productionAssets ? {
1270
- productionConfig: unit.production ?? {},
1468
+ productionConfig: normalizedProductionConfig(unit),
1271
1469
  frozenSnapshot: {
1272
1470
  path: productionAssets[idx].snapshotPath,
1273
1471
  manifestDigest: productionAssets[idx].manifestDigest,
1274
1472
  gitObjectDir: productionAssets[idx].gitObjectDir,
1275
1473
  branch: productionAssets[idx].branch,
1474
+ branchStrategy: productionAssets[idx].branchStrategy,
1276
1475
  commit: productionAssets[idx].commit,
1277
1476
  tree: productionAssets[idx].tree,
1477
+ ...(productionAssets[idx].parentCommit
1478
+ ? { parentCommit: productionAssets[idx].parentCommit }
1479
+ : {}),
1278
1480
  npm: productionAssets[idx].npm,
1279
1481
  },
1280
1482
  } : {}),
@@ -1300,6 +1502,7 @@ export async function prepareRelease(options) {
1300
1502
  capturedAt: baseline.capturedAt,
1301
1503
  },
1302
1504
  configDigest,
1505
+ verificationGates: config.verificationGates ?? [],
1303
1506
  snapshotDigest: overallSnapshotDigest,
1304
1507
  ...(production ? {
1305
1508
  production: {
@@ -75,6 +75,7 @@ const ACTION_NOT_ALLOWED = 'ACTION_NOT_ALLOWED';
75
75
  const CHECKPOINT_ORDER = [
76
76
  'push-commit',
77
77
  'push-snapshot',
78
+ 'set-default-branch',
78
79
  'create-tag',
79
80
  'npm-publish',
80
81
  'github-release',
@@ -92,6 +93,7 @@ const CHECKPOINT_ORDER = [
92
93
  const ADAPTER_ACTION_TYPE_MAP = {
93
94
  'push-commit': 'git-push',
94
95
  'push-snapshot': 'push-snapshot',
96
+ 'set-default-branch': 'set-default-branch',
95
97
  'create-tag': 'git-tag',
96
98
  'npm-publish': 'npm-publish',
97
99
  'github-release': 'github-release',
@@ -812,6 +814,49 @@ export async function publishRelease(options) {
812
814
  latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
813
815
  }
814
816
 
817
+ // Close the push -> default-branch TOCTOU window with a final read-only
818
+ // consistency pass. Both the branch tip and default-branch name are bound
819
+ // in the frozen action expectations. A late change keeps the saga PARTIAL
820
+ // and must be resolved by reconcile/human review.
821
+ if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
822
+ await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
823
+ for (let index = 0; index < orderedActions.length; index += 1) {
824
+ const action = orderedActions[index];
825
+ if (!['push-snapshot', 'set-default-branch'].includes(action.type)) continue;
826
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
827
+ const adapter = adapterRegistry.getAdapter(adapterActionType);
828
+ let observed;
829
+ try {
830
+ observed = await adapter.observe(
831
+ { actionType: adapterActionType, ...action.parameters, expected: action.expected },
832
+ { externalWritesAuthorized: false, plan: publishingPlan, baseline: plan.baseline, root, runDir },
833
+ );
834
+ } catch (error) {
835
+ observed = { observation: null, error: error.message };
836
+ }
837
+ const observation = observed?.observation;
838
+ const observable = observation && !(observed.error && Object.keys(observation).length === 0);
839
+ const comparison = observable ? matchObservation(action.expected ?? {}, observation) : { matches: false, mismatches: [] };
840
+ if (!comparison.matches) {
841
+ checkpoints[index].status = observable ? 'FAILED' : 'UNCERTAIN';
842
+ checkpoints[index].error = observable
843
+ ? `final branch consistency mismatch: ${comparison.mismatches.join('; ')}`
844
+ : `final branch consistency unobservable: ${observed?.error ?? 'empty observation'}`;
845
+ await evidence.append({
846
+ phase: 'safety-gate',
847
+ gate: 'final-branch-consistency',
848
+ status: 'failed',
849
+ actionId: action.id,
850
+ error: checkpoints[index].error,
851
+ });
852
+ break;
853
+ }
854
+ }
855
+ if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
856
+ await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
857
+ }
858
+ }
859
+
815
860
  // Determine overall status
816
861
  const hasFailure = checkpoints.some((cp) => cp.status === 'FAILED' || cp.status === 'UNCERTAIN');
817
862
  const allSucceeded = checkpoints.every((cp) => cp.status === 'SUCCEEDED');
@@ -71,6 +71,7 @@ import { matchObservation } from '../adapters/contract.mjs';
71
71
  const CHECKPOINT_ORDER = [
72
72
  'push-commit',
73
73
  'push-snapshot',
74
+ 'set-default-branch',
74
75
  'create-tag',
75
76
  'npm-publish',
76
77
  'github-release',
@@ -85,6 +86,7 @@ const CHECKPOINT_ORDER = [
85
86
  const ADAPTER_ACTION_TYPE_MAP = {
86
87
  'push-commit': 'git-push',
87
88
  'push-snapshot': 'push-snapshot',
89
+ 'set-default-branch': 'set-default-branch',
88
90
  'create-tag': 'git-tag',
89
91
  'npm-publish': 'npm-publish',
90
92
  'github-release': 'github-release',
@@ -444,6 +446,13 @@ export async function reconcileRelease(options) {
444
446
  baseline,
445
447
  observeFn: ppbObserveFn,
446
448
  evidence,
449
+ acceptedSuccessorCommits: (plan.externalActions ?? [])
450
+ .filter((action) => (
451
+ action.unitId === unit.id &&
452
+ action.type === 'push-snapshot' &&
453
+ action.parameters?.branchStrategy === 'advance-existing-branch'
454
+ ))
455
+ .map((action) => action.parameters.commit),
447
456
  });
448
457
  if (!observed.consistent) {
449
458
  throw new ReleaseError(
@@ -667,6 +676,59 @@ export async function reconcileRelease(options) {
667
676
  continue;
668
677
  }
669
678
 
679
+ // An advancing push has two safe observable states during recovery:
680
+ // the exact frozen predecessor (retry is still possible) or the exact
681
+ // planned successor (the release already advanced it). Any third tip is
682
+ // a real remote conflict. Generic create-only logic cannot distinguish
683
+ // the predecessor from an unexpected pre-existing branch.
684
+ if (
685
+ action.type === 'push-snapshot' &&
686
+ action.parameters?.branchStrategy === 'advance-existing-branch'
687
+ ) {
688
+ const observeResult = await adapter.observe(
689
+ { actionType: adapterActionType, ...action.parameters },
690
+ context,
691
+ );
692
+ const observation = observeResult?.observation;
693
+ if (!observation || (observeResult.error && Object.keys(observation).length === 0)) {
694
+ throw new ReleaseError(
695
+ REMOTE_CONFLICT,
696
+ `advancing action "${action.id}" remote state is unobservable`,
697
+ { actionId: action.id, observeError: observeResult?.error },
698
+ );
699
+ }
700
+ const actual = observation.commit ?? observation.remoteCommit ?? '';
701
+ const predecessor = action.parameters.expectedBaselineCommit;
702
+ const successor = action.parameters.commit;
703
+ if (actual === successor) {
704
+ actionResults.set(action.id, sourceCp.status === 'succeeded' ? 'succeeded' : 'skipped');
705
+ await evidence.append({
706
+ phase: 'reconcile-observe',
707
+ actionId: action.id,
708
+ actionType: action.type,
709
+ decision: 'advance-already-at-planned-successor',
710
+ sourceStatus: sourceCp.status,
711
+ });
712
+ continue;
713
+ }
714
+ if (actual === predecessor && sourceCp.status !== 'succeeded') {
715
+ actionsToRetry.push(action);
716
+ await evidence.append({
717
+ phase: 'reconcile-observe',
718
+ actionId: action.id,
719
+ actionType: action.type,
720
+ decision: 'retry-advance-from-frozen-predecessor',
721
+ sourceStatus: sourceCp.status,
722
+ });
723
+ continue;
724
+ }
725
+ throw new ReleaseError(
726
+ REMOTE_CONFLICT,
727
+ `Remote branch conflict for advancing action "${action.id}": expected predecessor ${predecessor} or planned successor ${successor}, got ${actual || 'missing'}`,
728
+ { actionId: action.id, predecessor, successor, actual, sourceStatus: sourceCp.status },
729
+ );
730
+ }
731
+
670
732
  // -------------------------------------------------------------------
671
733
  // Non-marketplace actions: observe-based consistency checks
672
734
  // -------------------------------------------------------------------
@@ -761,6 +823,57 @@ export async function reconcileRelease(options) {
761
823
  );
762
824
  }
763
825
 
826
+ if (action.type === 'set-default-branch') {
827
+ const current = observeResult.observation.defaultBranch;
828
+ const observedNewBranchCommit = observeResult.observation.newBranchCommit;
829
+ if (observedNewBranchCommit !== action.parameters.expectedNewBranchCommit) {
830
+ await evidence.append({
831
+ phase: 'reconcile-observe',
832
+ actionId: action.id,
833
+ actionType: action.type,
834
+ decision: 'remote-conflict',
835
+ expectedNewBranchCommit: action.parameters.expectedNewBranchCommit,
836
+ observedNewBranchCommit: observedNewBranchCommit ?? null,
837
+ });
838
+ throw new ReleaseError(
839
+ REMOTE_CONFLICT,
840
+ `Remote target branch commit conflict for action "${action.id}": expected ` +
841
+ `"${action.parameters.expectedNewBranchCommit}", got "${observedNewBranchCommit ?? 'missing'}"`,
842
+ {
843
+ actionId: action.id,
844
+ expectedNewBranchCommit: action.parameters.expectedNewBranchCommit,
845
+ observedNewBranchCommit: observedNewBranchCommit ?? null,
846
+ },
847
+ );
848
+ }
849
+ if (current === action.parameters.newBranch) {
850
+ actionResults.set(action.id, 'skipped');
851
+ await evidence.append({
852
+ phase: 'reconcile-observe',
853
+ actionId: action.id,
854
+ actionType: action.type,
855
+ decision: 'skip-remote-consistent',
856
+ });
857
+ continue;
858
+ }
859
+ if (current === action.parameters.oldBranch) {
860
+ actionsToRetry.push(action);
861
+ await evidence.append({
862
+ phase: 'reconcile-observe',
863
+ actionId: action.id,
864
+ actionType: action.type,
865
+ decision: 'retry-default-branch-still-old',
866
+ });
867
+ continue;
868
+ }
869
+ throw new ReleaseError(
870
+ REMOTE_CONFLICT,
871
+ `Remote default branch conflict for action "${action.id}": expected old ` +
872
+ `"${action.parameters.oldBranch}" or new "${action.parameters.newBranch}", got "${current}"`,
873
+ { actionId: action.id, observedDefaultBranch: current },
874
+ );
875
+ }
876
+
764
877
  const explicitlyMissing = observeResult.observation.exists === false
765
878
  || observeResult.observation.remoteCommit === ''
766
879
  || observeResult.observation.commit === ''
@@ -1137,6 +1250,45 @@ export async function reconcileRelease(options) {
1137
1250
  }
1138
1251
  }
1139
1252
 
1253
+ // Final branch/default-branch consistency closes late drift after a retry
1254
+ // or after the first observation pass. The set-default action expectation
1255
+ // includes both the branch name and its exact planned commit.
1256
+ if (!retryFailed && planActions.every((action) => ['succeeded', 'skipped'].includes(actionResults.get(action.id)))) {
1257
+ await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
1258
+ for (const action of planActions) {
1259
+ if (!['push-snapshot', 'set-default-branch'].includes(action.type)) continue;
1260
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
1261
+ const adapter = adapterRegistry.getAdapter(adapterActionType);
1262
+ let observed;
1263
+ try {
1264
+ observed = await adapter.observe(
1265
+ { actionType: adapterActionType, ...action.parameters, expected: action.expected },
1266
+ context,
1267
+ );
1268
+ } catch (error) {
1269
+ observed = { observation: null, error: error.message };
1270
+ }
1271
+ const observation = observed?.observation;
1272
+ const observable = observation && !(observed.error && Object.keys(observation).length === 0);
1273
+ const comparison = observable ? matchObservation(action.expected ?? {}, observation) : { matches: false, mismatches: [] };
1274
+ if (!comparison.matches) {
1275
+ actionResults.set(action.id, observable ? 'failed' : 'uncertain');
1276
+ retryFailed = true;
1277
+ await evidence.append({
1278
+ phase: 'safety-gate',
1279
+ gate: 'final-branch-consistency',
1280
+ status: 'failed',
1281
+ actionId: action.id,
1282
+ error: observable ? comparison.mismatches.join('; ') : observed?.error ?? 'empty observation',
1283
+ });
1284
+ break;
1285
+ }
1286
+ }
1287
+ if (!retryFailed) {
1288
+ await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
1289
+ }
1290
+ }
1291
+
1140
1292
  // =======================================================================
1141
1293
  // Determine final status
1142
1294
  // =======================================================================