release-skill 0.1.1 → 0.1.4

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 (108) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +3 -3
  4. package/CHANGELOG.md +89 -0
  5. package/INSTALL.md +216 -5
  6. package/INSTALL.zh-CN.md +358 -0
  7. package/README.md +411 -67
  8. package/README.zh-CN.md +377 -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/bin/release-skill.bundle.mjs +79284 -0
  12. package/adapters/claude/bin/release-skill.mjs +34 -0
  13. package/adapters/claude/native/safe-write/binding.gyp +40 -0
  14. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  15. package/adapters/claude/native/safe-write/prebuilds.json +24 -0
  16. package/adapters/claude/native/safe-write/src/safe_write.cc +2032 -0
  17. package/adapters/claude/schemas/.render-manifest.json +37 -0
  18. package/adapters/claude/schemas/approval-record.schema.json +115 -0
  19. package/adapters/claude/schemas/artifact-lock.schema.json +111 -0
  20. package/adapters/claude/schemas/artifact-plan.schema.json +52 -0
  21. package/adapters/claude/schemas/artifact-policy.schema.json +76 -0
  22. package/adapters/claude/schemas/evidence-event.schema.json +89 -0
  23. package/adapters/claude/schemas/release-plan.schema.json +860 -0
  24. package/adapters/claude/schemas/release-project.schema.json +736 -0
  25. package/adapters/claude/schemas/release-run.schema.json +342 -0
  26. package/adapters/claude/skills/release-assess/SKILL.md +5 -6
  27. package/adapters/claude/skills/release-help/SKILL.md +14 -18
  28. package/adapters/claude/skills/release-prepare/SKILL.md +16 -6
  29. package/adapters/claude/skills/release-publish/SKILL.md +7 -7
  30. package/adapters/claude/skills/release-reconcile/SKILL.md +6 -6
  31. package/adapters/claude/skills/release-setup/SKILL.md +95 -0
  32. package/adapters/claude/skills/release-verify/SKILL.md +7 -7
  33. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  34. package/adapters/codex/bin/release-skill.bundle.mjs +79284 -0
  35. package/adapters/codex/bin/release-skill.mjs +34 -0
  36. package/adapters/codex/native/safe-write/binding.gyp +40 -0
  37. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  38. package/adapters/codex/native/safe-write/prebuilds.json +24 -0
  39. package/adapters/codex/native/safe-write/src/safe_write.cc +2032 -0
  40. package/adapters/codex/schemas/.render-manifest.json +37 -0
  41. package/adapters/codex/schemas/approval-record.schema.json +115 -0
  42. package/adapters/codex/schemas/artifact-lock.schema.json +111 -0
  43. package/adapters/codex/schemas/artifact-plan.schema.json +52 -0
  44. package/adapters/codex/schemas/artifact-policy.schema.json +76 -0
  45. package/adapters/codex/schemas/evidence-event.schema.json +89 -0
  46. package/adapters/codex/schemas/release-plan.schema.json +860 -0
  47. package/adapters/codex/schemas/release-project.schema.json +736 -0
  48. package/adapters/codex/schemas/release-run.schema.json +342 -0
  49. package/adapters/codex/skills/release-assess/SKILL.md +12 -6
  50. package/adapters/codex/skills/release-help/SKILL.md +21 -18
  51. package/adapters/codex/skills/release-prepare/SKILL.md +23 -6
  52. package/adapters/codex/skills/release-publish/SKILL.md +14 -7
  53. package/adapters/codex/skills/release-reconcile/SKILL.md +13 -6
  54. package/adapters/codex/skills/release-setup/SKILL.md +102 -0
  55. package/adapters/codex/skills/release-verify/SKILL.md +14 -7
  56. package/bin/release-skill-cli.mjs +807 -0
  57. package/bin/release-skill.bundle.mjs +79284 -0
  58. package/bin/release-skill.mjs +23 -732
  59. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  60. package/native/safe-write/prebuilds.json +22 -2
  61. package/native/safe-write/src/safe_write.cc +11 -2
  62. package/package.json +8 -2
  63. package/references/02-project-config.md +55 -4
  64. package/references/05-evidence-and-errors.md +6 -2
  65. package/schemas/release-plan.schema.json +556 -65
  66. package/schemas/release-project.schema.json +406 -29
  67. package/schemas/release-run.schema.json +165 -18
  68. package/scripts/build-bundle.mjs +133 -0
  69. package/skills/release-assess/SKILL.md +5 -6
  70. package/skills/release-help/SKILL.md +14 -18
  71. package/skills/release-prepare/SKILL.md +16 -6
  72. package/skills/release-publish/SKILL.md +7 -7
  73. package/skills/release-reconcile/SKILL.md +6 -6
  74. package/skills/release-setup/SKILL.md +95 -0
  75. package/skills/release-verify/SKILL.md +7 -7
  76. package/skills-src/release-assess/SKILL.md +5 -6
  77. package/skills-src/release-help/SKILL.md +14 -18
  78. package/skills-src/release-prepare/SKILL.md +16 -6
  79. package/skills-src/release-publish/SKILL.md +7 -7
  80. package/skills-src/release-reconcile/SKILL.md +6 -6
  81. package/skills-src/release-setup/SKILL.md +95 -0
  82. package/skills-src/release-verify/SKILL.md +7 -7
  83. package/src/adapters/contract.mjs +3 -0
  84. package/src/adapters/git-github.mjs +84 -2
  85. package/src/adapters/npm.mjs +5 -13
  86. package/src/adapters/plugin-marketplace.mjs +132 -52
  87. package/src/adapters/push-snapshot.mjs +84 -17
  88. package/src/artifacts/policy.mjs +4 -7
  89. package/src/artifacts/safe-fs-backend-internal.mjs +69 -21
  90. package/src/commands/prepare.mjs +244 -20
  91. package/src/commands/publish.mjs +46 -0
  92. package/src/commands/reconcile.mjs +152 -0
  93. package/src/commands/setup.mjs +1525 -0
  94. package/src/commands/verify.mjs +122 -26
  95. package/src/core/approval.mjs +4 -6
  96. package/src/core/config.mjs +42 -8
  97. package/src/core/errors.mjs +4 -0
  98. package/src/core/pkg-root.mjs +22 -0
  99. package/src/core/plan.mjs +132 -4
  100. package/src/core/previous-public-baseline.mjs +21 -1
  101. package/src/core/run.mjs +4 -4
  102. package/src/core/trusted-resource.mjs +96 -0
  103. package/src/core/verification-gates.mjs +451 -0
  104. package/src/docs/version-gate.mjs +164 -0
  105. package/src/producers/build-adapters.mjs +512 -55
  106. package/src/snapshot/frozen.mjs +221 -7
  107. package/src/snapshot/public-map.mjs +7 -4
  108. package/src/snapshot/scan.mjs +2 -1
@@ -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';
@@ -36,6 +37,7 @@ import {
36
37
  buildFrozenGitRepository,
37
38
  buildFrozenNpmTarball,
38
39
  computeFrozenSnapshot,
40
+ normalizeGitTimestamp,
39
41
  sealFrozenSnapshot,
40
42
  } from '../snapshot/frozen.mjs';
41
43
  import { ReleaseError, GATE_FAILED, CONFIG_INVALID, FORBIDDEN_CONTENT_DETECTED } from '../core/errors.mjs';
@@ -483,7 +485,41 @@ async function processSnapshots(config, root, evidence, runDir, production = fal
483
485
  return { unitResults, snapshotDigests };
484
486
  }
485
487
 
486
- async function buildProductionAssets(unitResults, resolvedVersions, root, runDir) {
488
+ function resolveProductionBranch(unit, version) {
489
+ const tagTemplate = unit.version?.tagTemplate ?? `${unit.id}-v{version}`;
490
+ const tag = tagTemplate.replace('{version}', version);
491
+ const branchTemplate = unit.production?.branchTemplate ?? 'release/{tag}';
492
+ return {
493
+ tag,
494
+ branch: branchTemplate
495
+ .replaceAll('{tag}', tag)
496
+ .replaceAll('{version}', version)
497
+ .replaceAll('{unit}', unit.id),
498
+ branchStrategy: unit.production?.branchStrategy ?? 'create-release-branch',
499
+ };
500
+ }
501
+
502
+ function normalizedProductionConfig(unit) {
503
+ return {
504
+ ...(unit.production ?? {}),
505
+ githubHost: unit.production?.githubHost ?? 'github.com',
506
+ branchTemplate: unit.production?.branchTemplate ?? 'release/{tag}',
507
+ branchStrategy: unit.production?.branchStrategy ?? 'create-release-branch',
508
+ };
509
+ }
510
+
511
+ async function buildProductionAssets(
512
+ unitResults,
513
+ resolvedVersions,
514
+ root,
515
+ runDir,
516
+ unitBaselineResults,
517
+ buildGitRepository = buildFrozenGitRepository,
518
+ freezeTimestamp,
519
+ ) {
520
+ // The plan freeze timestamp is sampled exactly once by prepareRelease.
521
+ // Every unit reuses this single value; the wall clock is never re-read.
522
+ const canonicalFreezeTimestamp = normalizeGitTimestamp(freezeTimestamp, 'plan freeze timestamp');
487
523
  for (const { unit } of unitResults) {
488
524
  const npmDistribution = (unit.distributions ?? []).find((distribution) => distribution.type === 'npm');
489
525
  if (npmDistribution && !['public', 'restricted'].includes(npmDistribution.access)) {
@@ -497,13 +533,7 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
497
533
  for (let index = 0; index < unitResults.length; index += 1) {
498
534
  const { unit, manifest } = unitResults[index];
499
535
  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);
536
+ const { tag, branch, branchStrategy } = resolveProductionBranch(unit, version);
507
537
  const snapshotPath = relative(root, manifest.outputDir);
508
538
  const observed = await computeFrozenSnapshot(manifest.outputDir);
509
539
  if (observed.digest !== manifest.snapshotDigest) {
@@ -521,11 +551,22 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
521
551
  const sealed = await computeFrozenSnapshot(manifest.outputDir);
522
552
 
523
553
  const repositoryDir = resolveUnitScopedPath(resolve(runDir, 'git'), unit.id, { suffix: '.git' });
524
- const git = await buildFrozenGitRepository({
554
+ const unitBaseline = unitBaselineResults.get(unit.id);
555
+ const parent = branchStrategy === 'create-release-branch'
556
+ ? undefined
557
+ : {
558
+ githubHost: unitBaseline.githubHost,
559
+ repo: unitBaseline.repo,
560
+ ref: unitBaseline.ref,
561
+ commit: unitBaseline.commit,
562
+ };
563
+ const git = await buildGitRepository({
525
564
  snapshotDir: manifest.outputDir,
526
565
  repositoryDir,
527
566
  version,
528
567
  expectedSnapshotDigest: sealed.digest,
568
+ parent,
569
+ commitTimestamp: canonicalFreezeTimestamp,
529
570
  });
530
571
 
531
572
  let npm = null;
@@ -551,6 +592,9 @@ async function buildProductionAssets(unitResults, resolvedVersions, root, runDir
551
592
  gitObjectDir: relative(root, repositoryDir),
552
593
  commit: git.commit,
553
594
  tree: git.tree,
595
+ commitTimestamp: canonicalFreezeTimestamp,
596
+ branchStrategy,
597
+ ...(git.parentCommit ? { parentCommit: git.parentCommit } : {}),
554
598
  branch,
555
599
  tag,
556
600
  npm: npm ? {
@@ -725,6 +769,11 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
725
769
  githubHost: unit.production?.githubHost ?? 'github.com',
726
770
  commit: asset.commit,
727
771
  tree: asset.tree,
772
+ branchStrategy: asset.branchStrategy,
773
+ ...(asset.parentCommit ? { parentCommit: asset.parentCommit } : {}),
774
+ ...(asset.branchStrategy === 'advance-existing-branch'
775
+ ? { expectedBaselineCommit: asset.parentCommit }
776
+ : {}),
728
777
  },
729
778
  expected: {
730
779
  branch: asset.branch,
@@ -735,6 +784,24 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
735
784
  status: 'PENDING',
736
785
  });
737
786
 
787
+ if (asset.branchStrategy === 'initialize-default-branch') {
788
+ actions.push({
789
+ id: `set-default-branch-${unit.id}`,
790
+ type: 'set-default-branch',
791
+ adapter: 'git-github',
792
+ unitId: unit.id,
793
+ parameters: {
794
+ repo: unit.publicRepo,
795
+ githubHost: unit.production?.githubHost ?? 'github.com',
796
+ oldBranch: unit.production.expectedCurrentDefaultBranch,
797
+ newBranch: asset.branch,
798
+ expectedNewBranchCommit: asset.commit,
799
+ },
800
+ expected: { defaultBranch: asset.branch, newBranchCommit: asset.commit },
801
+ status: 'PENDING',
802
+ });
803
+ }
804
+
738
805
  // Create tag
739
806
  actions.push({
740
807
  id: `create-tag-${unit.id}`,
@@ -908,6 +975,8 @@ function buildExternalActions(unitResults, resolvedVersions, productionAssets) {
908
975
  * the project config declares hooks. Hooks are user-configured arbitrary
909
976
  * local processes without filesystem/network isolation. Authorization
910
977
  * means the user accepts hook side-effect risks, not that hooks are safe.
978
+ * @param {boolean} [options.verificationGatesAuthorized] - Must be explicitly
979
+ * true when project verification gates are declared.
911
980
  *
912
981
  * @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string }>}
913
982
  *
@@ -922,6 +991,7 @@ export async function prepareRelease(options) {
922
991
  runDir: runDirOpt,
923
992
  clock,
924
993
  hooksAuthorized,
994
+ verificationGatesAuthorized,
925
995
  production = false,
926
996
  observePreviousPublicBaselineFn,
927
997
  } = options ?? {};
@@ -1048,6 +1118,43 @@ export async function prepareRelease(options) {
1048
1118
  });
1049
1119
  }
1050
1120
 
1121
+ const declaredVerificationGates = config.verificationGates ?? [];
1122
+ if (declaredVerificationGates.length > 0) {
1123
+ await evidence.append({
1124
+ phase: 'verification-gate-authorization',
1125
+ status: 'started',
1126
+ gateCount: declaredVerificationGates.length,
1127
+ gates: declaredVerificationGates.map((gate) => ({
1128
+ id: gate.id,
1129
+ phase: gate.phase,
1130
+ unitId: gate.scope.unit,
1131
+ distribution: gate.scope.distribution ?? null,
1132
+ executable: gate.command[0],
1133
+ args: gate.command.slice(1),
1134
+ cwd: gate.cwd,
1135
+ })),
1136
+ });
1137
+ if (verificationGatesAuthorized !== true) {
1138
+ await evidence.append({
1139
+ phase: 'verification-gate-authorization',
1140
+ status: 'denied',
1141
+ gateCount: declaredVerificationGates.length,
1142
+ });
1143
+ throw new ReleaseError(
1144
+ GATE_FAILED,
1145
+ `project declares ${declaredVerificationGates.length} verification gate(s). ` +
1146
+ 'They run local project commands without a network sandbox. ' +
1147
+ 'To proceed, pass --acknowledge-gate-side-effects (CLI) or verificationGatesAuthorized=true (API).',
1148
+ { gateIds: declaredVerificationGates.map((gate) => gate.id) },
1149
+ );
1150
+ }
1151
+ await evidence.append({
1152
+ phase: 'verification-gate-authorization',
1153
+ status: 'authorized',
1154
+ gateCount: declaredVerificationGates.length,
1155
+ });
1156
+ }
1157
+
1051
1158
  // --- Step 3: Run declared hooks ---
1052
1159
  await evidence.append({ phase: 'hooks', status: 'started' });
1053
1160
  await runDeclaredHooks(config, realRoot, evidence);
@@ -1069,6 +1176,12 @@ export async function prepareRelease(options) {
1069
1176
 
1070
1177
  // --- Step 4b: Per-unit previous public baseline observe ---
1071
1178
  const configUnits = config.releaseUnits ?? [];
1179
+ const resolvedVersions = await resolveAllUnitVersions(
1180
+ configUnits,
1181
+ realRoot,
1182
+ version,
1183
+ evidence,
1184
+ );
1072
1185
  const defaultObserveFn = async (repo, ref, expectedCommit, { githubHost = 'github.com' } = {}) => {
1073
1186
  try {
1074
1187
  const { stdout } = await execFile("git", ["ls-remote", `https://${githubHost}/${repo}.git`, ref], {
@@ -1084,11 +1197,57 @@ export async function prepareRelease(options) {
1084
1197
  }
1085
1198
  };
1086
1199
  const observeFn = options.observePreviousPublicBaselineFn ?? defaultObserveFn;
1200
+ const defaultObserveDefaultBranchFn = async (repo, { githubHost = 'github.com' } = {}) => {
1201
+ try {
1202
+ const { stdout } = await execFile(
1203
+ 'gh',
1204
+ ['api', `repos/${repo}`, '--jq', '.default_branch'],
1205
+ {
1206
+ shell: false,
1207
+ encoding: 'utf8',
1208
+ timeout: 30000,
1209
+ env: { ...process.env, GH_HOST: githubHost },
1210
+ },
1211
+ );
1212
+ return { status: 'observed', defaultBranch: stdout.trim() };
1213
+ } catch (error) {
1214
+ return { status: 'unknown', error: error.message };
1215
+ }
1216
+ };
1217
+ const observeDefaultBranch = options.observeDefaultBranchFn ?? defaultObserveDefaultBranchFn;
1087
1218
  const unitBaselineResults = new Map();
1088
- for (const unit of configUnits) {
1219
+ for (let unitIndex = 0; unitIndex < configUnits.length; unitIndex += 1) {
1220
+ const unit = configUnits[unitIndex];
1089
1221
  const ppbConfig = unit.previousPublicBaseline;
1090
1222
  if (!ppbConfig) continue;
1091
1223
  const productionGithubHost = unit.production?.githubHost ?? 'github.com';
1224
+ const { branch, branchStrategy } = resolveProductionBranch(unit, resolvedVersions[unitIndex]);
1225
+ if (production && ['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
1226
+ if (offline) {
1227
+ throw new ReleaseError(
1228
+ GATE_FAILED,
1229
+ `unit "${unit.id}" branch strategy "${branchStrategy}" requires online production prepare`,
1230
+ { unitId: unit.id, branchStrategy },
1231
+ );
1232
+ }
1233
+ if (ppbConfig.mode !== 'bound') {
1234
+ throw new ReleaseError(
1235
+ GATE_FAILED,
1236
+ `unit "${unit.id}" branch strategy "${branchStrategy}" requires previousPublicBaseline.mode=bound`,
1237
+ { unitId: unit.id, branchStrategy },
1238
+ );
1239
+ }
1240
+ if (
1241
+ branchStrategy === 'advance-existing-branch' &&
1242
+ ppbConfig.ref !== `refs/heads/${branch}`
1243
+ ) {
1244
+ throw new ReleaseError(
1245
+ GATE_FAILED,
1246
+ `unit "${unit.id}" advance-existing-branch baseline ref must equal refs/heads/${branch}`,
1247
+ { unitId: unit.id, expectedRef: `refs/heads/${branch}`, actualRef: ppbConfig.ref },
1248
+ );
1249
+ }
1250
+ }
1092
1251
  const effectivePpbConfig = ppbConfig.mode === 'bound'
1093
1252
  ? { ...ppbConfig, githubHost: productionGithubHost }
1094
1253
  : ppbConfig;
@@ -1214,6 +1373,37 @@ export async function prepareRelease(options) {
1214
1373
  status: "completed",
1215
1374
  consistent: true,
1216
1375
  });
1376
+
1377
+ if (production && branchStrategy === 'initialize-default-branch') {
1378
+ const expectedCurrent = unit.production?.expectedCurrentDefaultBranch;
1379
+ const observedDefault = await observeDefaultBranch(unit.publicRepo, {
1380
+ githubHost: productionGithubHost,
1381
+ });
1382
+ await evidence.append({
1383
+ phase: 'default-branch-observe',
1384
+ unitId: unit.id,
1385
+ status: observedDefault.status,
1386
+ expectedCurrentDefaultBranch: expectedCurrent,
1387
+ observedCurrentDefaultBranch: observedDefault.defaultBranch ?? null,
1388
+ ...(observedDefault.error ? { error: observedDefault.error } : {}),
1389
+ });
1390
+ if (
1391
+ observedDefault.status !== 'observed' ||
1392
+ !observedDefault.defaultBranch ||
1393
+ observedDefault.defaultBranch !== expectedCurrent
1394
+ ) {
1395
+ throw new ReleaseError(
1396
+ GATE_FAILED,
1397
+ `unit "${unit.id}" GitHub default branch does not match expectedCurrentDefaultBranch`,
1398
+ {
1399
+ unitId: unit.id,
1400
+ expectedCurrentDefaultBranch: expectedCurrent,
1401
+ observedCurrentDefaultBranch: observedDefault.defaultBranch ?? null,
1402
+ observationStatus: observedDefault.status,
1403
+ },
1404
+ );
1405
+ }
1406
+ }
1217
1407
  }
1218
1408
 
1219
1409
  // --- Step 5: Build snapshots, scan, and evaluate README ---
@@ -1221,6 +1411,22 @@ export async function prepareRelease(options) {
1221
1411
  config, realRoot, evidence, runDir, production,
1222
1412
  );
1223
1413
 
1414
+ // Snapshot gates always run on disposable writable copies. The public
1415
+ // snapshot authority is re-digested after every gate and is never exposed
1416
+ // as the gate working directory.
1417
+ const snapshotGateResults = await runSnapshotVerificationGates({
1418
+ gates: declaredVerificationGates,
1419
+ unitResults,
1420
+ runDir,
1421
+ evidence,
1422
+ env: options.gateEnv ?? process.env,
1423
+ });
1424
+ await evidence.append({
1425
+ phase: 'snapshot-verify',
1426
+ status: 'completed',
1427
+ gateCount: snapshotGateResults.length,
1428
+ });
1429
+
1224
1430
  // --- Step 6: Remote uniqueness (deferred to publish preflight) ---
1225
1431
  // Prepare only observes the previous public baseline (already done above).
1226
1432
  // Remote uniqueness checks (tag, GitHub Release, npm version) are deferred
@@ -1244,16 +1450,28 @@ export async function prepareRelease(options) {
1244
1450
  // --- Step 7: Build plan object ---
1245
1451
  await evidence.append({ phase: 'plan-assembly', status: 'started' });
1246
1452
 
1247
- // Resolve versions for all units
1248
- const resolvedVersions = await resolveAllUnitVersions(
1249
- config.releaseUnits ?? [],
1250
- realRoot,
1251
- version,
1252
- evidence,
1253
- );
1453
+ // Production plans sample their freeze timestamp exactly once, before the
1454
+ // first frozen Git object exists. This single canonical value becomes
1455
+ // GIT_AUTHOR_DATE/GIT_COMMITTER_DATE for every unit's frozen commit,
1456
+ // every unit's frozenSnapshot.commitTimestamp, and plan.createdAt. It is
1457
+ // thereby bound by the plan digest and the approval record; publish,
1458
+ // retry, and reconcile consume it from the frozen plan and never re-read
1459
+ // the wall clock. A missing or invalid injected value fails closed here,
1460
+ // before any Git write.
1461
+ const freezeTimestamp = production
1462
+ ? normalizeGitTimestamp(clock ? clock() : new Date().toISOString(), 'plan freeze timestamp')
1463
+ : null;
1254
1464
 
1255
1465
  const productionAssets = production
1256
- ? await buildProductionAssets(unitResults, resolvedVersions, realRoot, runDir)
1466
+ ? await buildProductionAssets(
1467
+ unitResults,
1468
+ resolvedVersions,
1469
+ realRoot,
1470
+ runDir,
1471
+ unitBaselineResults,
1472
+ options.buildFrozenGitRepositoryFn ?? buildFrozenGitRepository,
1473
+ freezeTimestamp,
1474
+ )
1257
1475
  : null;
1258
1476
 
1259
1477
  const units = unitResults.map(({ unit, manifest }, idx) => {
@@ -1267,14 +1485,19 @@ export async function prepareRelease(options) {
1267
1485
  tagTemplate: unit.version?.tagTemplate,
1268
1486
  snapshotDigest: snapshotDigests[idx],
1269
1487
  ...(productionAssets ? {
1270
- productionConfig: unit.production ?? {},
1488
+ productionConfig: normalizedProductionConfig(unit),
1271
1489
  frozenSnapshot: {
1272
1490
  path: productionAssets[idx].snapshotPath,
1273
1491
  manifestDigest: productionAssets[idx].manifestDigest,
1274
1492
  gitObjectDir: productionAssets[idx].gitObjectDir,
1275
1493
  branch: productionAssets[idx].branch,
1494
+ branchStrategy: productionAssets[idx].branchStrategy,
1276
1495
  commit: productionAssets[idx].commit,
1277
1496
  tree: productionAssets[idx].tree,
1497
+ commitTimestamp: productionAssets[idx].commitTimestamp,
1498
+ ...(productionAssets[idx].parentCommit
1499
+ ? { parentCommit: productionAssets[idx].parentCommit }
1500
+ : {}),
1278
1501
  npm: productionAssets[idx].npm,
1279
1502
  },
1280
1503
  } : {}),
@@ -1300,6 +1523,7 @@ export async function prepareRelease(options) {
1300
1523
  capturedAt: baseline.capturedAt,
1301
1524
  },
1302
1525
  configDigest,
1526
+ verificationGates: config.verificationGates ?? [],
1303
1527
  snapshotDigest: overallSnapshotDigest,
1304
1528
  ...(production ? {
1305
1529
  production: {
@@ -1309,7 +1533,7 @@ export async function prepareRelease(options) {
1309
1533
  } : {}),
1310
1534
  units,
1311
1535
  externalActions,
1312
- createdAt: (clock ? clock() : new Date().toISOString()),
1536
+ createdAt: production ? freezeTimestamp : (clock ? clock() : new Date().toISOString()),
1313
1537
  };
1314
1538
 
1315
1539
  await evidence.append({
@@ -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',
@@ -387,6 +389,7 @@ export async function publishRelease(options) {
387
389
  gitObjectDir: frozen.gitObjectDir,
388
390
  commit: frozen.commit,
389
391
  tree: frozen.tree,
392
+ commitTimestamp: frozen.commitTimestamp,
390
393
  });
391
394
  assertInsideAssetRoot(assetRoot, git.gitDir, 'frozen git object directory');
392
395
  if (frozen.npm) {
@@ -812,6 +815,49 @@ export async function publishRelease(options) {
812
815
  latestState = await appendRunState(runDir, stateSequence, buildPersistedState(PARTIAL));
813
816
  }
814
817
 
818
+ // Close the push -> default-branch TOCTOU window with a final read-only
819
+ // consistency pass. Both the branch tip and default-branch name are bound
820
+ // in the frozen action expectations. A late change keeps the saga PARTIAL
821
+ // and must be resolved by reconcile/human review.
822
+ if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
823
+ await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'started' });
824
+ for (let index = 0; index < orderedActions.length; index += 1) {
825
+ const action = orderedActions[index];
826
+ if (!['push-snapshot', 'set-default-branch'].includes(action.type)) continue;
827
+ const adapterActionType = ADAPTER_ACTION_TYPE_MAP[action.type];
828
+ const adapter = adapterRegistry.getAdapter(adapterActionType);
829
+ let observed;
830
+ try {
831
+ observed = await adapter.observe(
832
+ { actionType: adapterActionType, ...action.parameters, expected: action.expected },
833
+ { externalWritesAuthorized: false, plan: publishingPlan, baseline: plan.baseline, root, runDir },
834
+ );
835
+ } catch (error) {
836
+ observed = { observation: null, error: error.message };
837
+ }
838
+ const observation = observed?.observation;
839
+ const observable = observation && !(observed.error && Object.keys(observation).length === 0);
840
+ const comparison = observable ? matchObservation(action.expected ?? {}, observation) : { matches: false, mismatches: [] };
841
+ if (!comparison.matches) {
842
+ checkpoints[index].status = observable ? 'FAILED' : 'UNCERTAIN';
843
+ checkpoints[index].error = observable
844
+ ? `final branch consistency mismatch: ${comparison.mismatches.join('; ')}`
845
+ : `final branch consistency unobservable: ${observed?.error ?? 'empty observation'}`;
846
+ await evidence.append({
847
+ phase: 'safety-gate',
848
+ gate: 'final-branch-consistency',
849
+ status: 'failed',
850
+ actionId: action.id,
851
+ error: checkpoints[index].error,
852
+ });
853
+ break;
854
+ }
855
+ }
856
+ if (checkpoints.every((cp) => cp.status === 'SUCCEEDED')) {
857
+ await evidence.append({ phase: 'safety-gate', gate: 'final-branch-consistency', status: 'passed' });
858
+ }
859
+ }
860
+
815
861
  // Determine overall status
816
862
  const hasFailure = checkpoints.some((cp) => cp.status === 'FAILED' || cp.status === 'UNCERTAIN');
817
863
  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
  // =======================================================================