release-skill 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +27 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +15 -14
  10. package/README.zh-CN.md +16 -15
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +3779 -769
  14. package/adapters/claude/schemas/.render-manifest.json +6 -6
  15. package/adapters/claude/schemas/release-plan.schema.json +401 -2
  16. package/adapters/claude/schemas/release-project.schema.json +299 -0
  17. package/adapters/claude/schemas/release-run.schema.json +59 -6
  18. package/adapters/claude/skills/release-config/SKILL.md +140 -0
  19. package/adapters/claude/skills/release-docs/SKILL.md +106 -0
  20. package/adapters/claude/skills/release-help/SKILL.md +39 -0
  21. package/adapters/claude/skills/release-marketplace/SKILL.md +147 -0
  22. package/adapters/claude/skills/release-publish/SKILL.md +25 -6
  23. package/adapters/claude/skills/release-verify/SKILL.md +10 -0
  24. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  25. package/adapters/codex/bin/release-skill.bundle.mjs +3779 -769
  26. package/adapters/codex/schemas/.render-manifest.json +6 -6
  27. package/adapters/codex/schemas/release-plan.schema.json +401 -2
  28. package/adapters/codex/schemas/release-project.schema.json +299 -0
  29. package/adapters/codex/schemas/release-run.schema.json +59 -6
  30. package/adapters/codex/skills/release-config/SKILL.md +147 -0
  31. package/adapters/codex/skills/release-docs/SKILL.md +113 -0
  32. package/adapters/codex/skills/release-help/SKILL.md +39 -0
  33. package/adapters/codex/skills/release-marketplace/SKILL.md +154 -0
  34. package/adapters/codex/skills/release-publish/SKILL.md +25 -6
  35. package/adapters/codex/skills/release-verify/SKILL.md +10 -0
  36. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  37. package/adapters/kimi/bin/release-skill.bundle.mjs +3779 -769
  38. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  39. package/adapters/kimi/schemas/release-plan.schema.json +401 -2
  40. package/adapters/kimi/schemas/release-project.schema.json +299 -0
  41. package/adapters/kimi/schemas/release-run.schema.json +59 -6
  42. package/adapters/kimi/skills/release-config/SKILL.md +147 -0
  43. package/adapters/kimi/skills/release-docs/SKILL.md +113 -0
  44. package/adapters/kimi/skills/release-help/SKILL.md +39 -0
  45. package/adapters/kimi/skills/release-marketplace/SKILL.md +154 -0
  46. package/adapters/kimi/skills/release-publish/SKILL.md +25 -6
  47. package/adapters/kimi/skills/release-verify/SKILL.md +10 -0
  48. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  49. package/adapters/workbuddy/bin/release-skill.bundle.mjs +3779 -769
  50. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  51. package/adapters/workbuddy/schemas/release-plan.schema.json +401 -2
  52. package/adapters/workbuddy/schemas/release-project.schema.json +299 -0
  53. package/adapters/workbuddy/schemas/release-run.schema.json +59 -6
  54. package/adapters/workbuddy/skills/release-config/SKILL.md +140 -0
  55. package/adapters/workbuddy/skills/release-docs/SKILL.md +106 -0
  56. package/adapters/workbuddy/skills/release-help/SKILL.md +39 -0
  57. package/adapters/workbuddy/skills/release-marketplace/SKILL.md +147 -0
  58. package/adapters/workbuddy/skills/release-publish/SKILL.md +25 -6
  59. package/adapters/workbuddy/skills/release-verify/SKILL.md +10 -0
  60. package/bin/release-skill-cli.mjs +246 -7
  61. package/bin/release-skill.bundle.mjs +3779 -769
  62. package/package.json +3 -2
  63. package/platform-manifest.json +359 -0
  64. package/references/.render-manifest.json +9 -9
  65. package/references/02-project-config.md +35 -0
  66. package/references/05-evidence-and-errors.md +45 -0
  67. package/references/06-adapter-contract.md +13 -0
  68. package/schemas/.render-manifest.json +6 -6
  69. package/schemas/release-plan.schema.json +401 -2
  70. package/schemas/release-project.schema.json +299 -0
  71. package/schemas/release-run.schema.json +59 -6
  72. package/scripts/build-bundle.mjs +11 -2
  73. package/scripts/sync-public-files.mjs +4 -0
  74. package/skills/release-config/SKILL.md +140 -0
  75. package/skills/release-docs/SKILL.md +106 -0
  76. package/skills/release-help/SKILL.md +39 -0
  77. package/skills/release-marketplace/SKILL.md +147 -0
  78. package/skills/release-publish/SKILL.md +25 -6
  79. package/skills/release-verify/SKILL.md +10 -0
  80. package/skills-src/release-config/SKILL.md +140 -0
  81. package/skills-src/release-docs/SKILL.md +106 -0
  82. package/skills-src/release-help/SKILL.md +39 -0
  83. package/skills-src/release-marketplace/SKILL.md +147 -0
  84. package/skills-src/release-publish/SKILL.md +25 -6
  85. package/skills-src/release-verify/SKILL.md +10 -0
  86. package/src/adapters/contract.mjs +5 -0
  87. package/src/adapters/distribute-git.mjs +625 -0
  88. package/src/commands/distribute.mjs +1078 -0
  89. package/src/commands/lineage.mjs +616 -0
  90. package/src/commands/prepare.mjs +548 -83
  91. package/src/commands/route.mjs +686 -0
  92. package/src/commands/ship.mjs +45 -1
  93. package/src/commands/verify.mjs +198 -0
  94. package/src/core/baseline-advance.mjs +185 -0
  95. package/src/core/bundle-freshness.mjs +236 -0
  96. package/src/core/checkpoints.mjs +25 -0
  97. package/src/core/errors.mjs +2 -0
  98. package/src/core/frozen-marker.mjs +97 -0
  99. package/src/core/hooks.mjs +12 -1
  100. package/src/core/postpublish.mjs +315 -0
@@ -60,6 +60,7 @@ async function defaultDependencies() {
60
60
  publishModule,
61
61
  reconcileModule,
62
62
  verifyModule,
63
+ distributeModule,
63
64
  transportModule,
64
65
  metadataModule,
65
66
  ] = await Promise.all([
@@ -69,6 +70,7 @@ async function defaultDependencies() {
69
70
  import('./publish.mjs'),
70
71
  import('./reconcile.mjs'),
71
72
  import('./verify.mjs'),
73
+ import('./distribute.mjs'),
72
74
  import('../core/git-transport.mjs'),
73
75
  import('../core/release-metadata.mjs'),
74
76
  ]);
@@ -79,6 +81,7 @@ async function defaultDependencies() {
79
81
  publishRelease: publishModule.publishRelease,
80
82
  reconcileRelease: reconcileModule.reconcileRelease,
81
83
  verifyRelease: verifyModule.verifyRelease,
84
+ distributeRelease: distributeModule.distributeRelease,
82
85
  preflightGitTransports: transportModule.preflightGitTransports,
83
86
  updatePreviousPublicBaselines: metadataModule.updatePreviousPublicBaselines,
84
87
  };
@@ -102,6 +105,7 @@ function publicState(state) {
102
105
  ...(state.approvalSummary ? { approvalSummary: state.approvalSummary } : {}),
103
106
  ...(state.approvalPath ? { approvalPath: state.approvalPath } : {}),
104
107
  ...(state.sourceRunPath ? { sourceRunPath: state.sourceRunPath } : {}),
108
+ ...(state.distributeRunPath ? { distributeRunPath: state.distributeRunPath } : {}),
105
109
  ...(state.requirements ? { requirements: state.requirements } : {}),
106
110
  ...(state.manualFollowUps ? { manualFollowUps: state.manualFollowUps } : {}),
107
111
  ...(state.metadataUpdate ? { metadataUpdate: state.metadataUpdate } : {}),
@@ -301,10 +305,49 @@ export async function advanceShip(options = {}, injected = {}) {
301
305
  }
302
306
 
303
307
  if (state.status === 'PUBLISHED' || state.status === 'NEEDS_MANUAL_ATTESTATIONS') {
308
+ // Step 1: Check if postPublish requires distribution
309
+ const plan = JSON.parse(await readFile(state.planPath, 'utf8'));
310
+ const needsDistribution = plan.postPublish && plan.postPublish.targets && plan.postPublish.targets.length > 0;
311
+
312
+ if (needsDistribution && deps.distributeRelease) {
313
+ state.status = 'DISTRIBUTING';
314
+ await writeJsonAtomic(statePath, state);
315
+
316
+ try {
317
+ const distributed = await deps.distributeRelease({
318
+ sourceRunPath: state.sourceRunPath,
319
+ approvalPath: state.approvalPath,
320
+ adapterRegistry: options.adapterRegistry,
321
+ root,
322
+ dryRun: false,
323
+ planPath: state.planPath,
324
+ });
325
+
326
+ state = {
327
+ ...state,
328
+ status: distributed.status,
329
+ distributeRunPath: distributed.distributeRunPath,
330
+ updatedAt: new Date().toISOString(),
331
+ };
332
+ await writeJsonAtomic(statePath, state);
333
+
334
+ if (!distributed.checkpoints || distributed.checkpoints.some((cp) => cp.status === 'failed')) {
335
+ state.status = 'PARTIAL';
336
+ await writeJsonAtomic(statePath, state);
337
+ return publicState(state);
338
+ }
339
+ } catch (error) {
340
+ state.status = 'PARTIAL';
341
+ await writeJsonAtomic(statePath, state);
342
+ throw error;
343
+ }
344
+ }
345
+
346
+ // Step 2: Verify (this will check for distributed run if distribution was required)
304
347
  try {
305
348
  const verified = await deps.verifyRelease({
306
349
  planPath: state.planPath,
307
- sourceRunPath: state.sourceRunPath,
350
+ sourceRunPath: needsDistribution ? state.distributeRunPath : state.sourceRunPath,
308
351
  adapterRegistry: options.adapterRegistry,
309
352
  root,
310
353
  verificationGatesAuthorized: state.verificationGatesAuthorized === true,
@@ -315,6 +358,7 @@ export async function advanceShip(options = {}, injected = {}) {
315
358
  verifyRunPath: verified.runPath,
316
359
  requirements: undefined,
317
360
  manualFollowUps: verified.manualFollowUps ?? undefined,
361
+ baselineAdvance: verified.baselineAdvance ?? undefined,
318
362
  updatedAt: new Date().toISOString(),
319
363
  };
320
364
  await writeJsonAtomic(statePath, state);
@@ -46,7 +46,15 @@ import {
46
46
  CONSUMER_VERIFICATION_DEFERRED,
47
47
  } from '../core/errors.mjs';
48
48
  import { verifySourceAuthorityReceipt } from '../core/source-authority.mjs';
49
+ import {
50
+ deriveBaselineAdvances,
51
+ applyBaselineAdvances,
52
+ isWorktreeFileClean,
53
+ commitBaselineAdvance,
54
+ } from '../core/baseline-advance.mjs';
55
+ import { loadProjectConfig } from '../core/config.mjs';
49
56
  import { assertTransition, PUBLISHED, VERIFIED } from '../core/state-machine.mjs';
57
+ import { clearFrozenMarker } from '../core/frozen-marker.mjs';
50
58
  import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
51
59
  import {
52
60
  normalizeRegistry,
@@ -725,6 +733,95 @@ const defaultNpmExecutor = {
725
733
  },
726
734
  };
727
735
 
736
+ // ---------------------------------------------------------------------------
737
+ // Distribute run discovery
738
+ // ---------------------------------------------------------------------------
739
+
740
+ /**
741
+ * Discover sibling distribute runs from the same planDigest within .release-skill/runs.
742
+ * Only accept verified, non-symbolic-link directories matching distribute-* prefix.
743
+ */
744
+ async function discoverDistributeRuns({ planPath, plan }) {
745
+ const planDir = dirname(planPath);
746
+ const releaseDir = basename(planDir) === 'plans' ? dirname(planDir) : planDir;
747
+ const runsDir = resolve(releaseDir, 'runs');
748
+
749
+ try {
750
+ const runsDirStat = await lstat(runsDir);
751
+ if (runsDirStat.isSymbolicLink()) {
752
+ throw new ReleaseError(
753
+ GATE_FAILED,
754
+ 'runs directory is a symbolic link; authority identity compromised',
755
+ { runsDir },
756
+ );
757
+ }
758
+ if (!runsDirStat.isDirectory()) {
759
+ throw new ReleaseError(
760
+ GATE_FAILED,
761
+ 'runs path is not a directory',
762
+ { runsDir },
763
+ );
764
+ }
765
+
766
+ const runsDirReal = realpathSync(runsDir);
767
+ const authorityDirReal = realpathSync(releaseDir);
768
+ if (!runsDirReal.startsWith(authorityDirReal + '/') && runsDirReal !== authorityDirReal) {
769
+ throw new ReleaseError(
770
+ GATE_FAILED,
771
+ 'runs directory is not a real child of the plan authority directory',
772
+ { runsDir, runsDirReal, authorityDirReal },
773
+ );
774
+ }
775
+ } catch (err) {
776
+ if (err instanceof ReleaseError) throw err;
777
+ // runs directory doesn't exist — this is okay for optional distribution
778
+ return null;
779
+ }
780
+
781
+ const candidates = [];
782
+ const entries = await readdir(runsDir, { withFileTypes: true });
783
+ for (const entry of entries) {
784
+ if (!entry.name.startsWith('distribute-')) continue;
785
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
786
+ throw new ReleaseError(
787
+ GATE_FAILED,
788
+ 'distribute-* candidate is a symbolic link or not a directory; authority identity compromised',
789
+ { entry: entry.name, runsDir },
790
+ );
791
+ }
792
+ const candidateDir = resolve(runsDir, entry.name);
793
+ const candidateStat = await lstat(candidateDir).catch(() => null);
794
+ if (!candidateStat || candidateStat.isSymbolicLink()) {
795
+ throw new ReleaseError(
796
+ GATE_FAILED,
797
+ 'distribute-* candidate is a symbolic link; authority identity compromised',
798
+ { candidateDir, runsDir },
799
+ );
800
+ }
801
+ const candidateReal = realpathSync(candidateDir);
802
+ if (!candidateReal.startsWith(runsDir + '/') && candidateReal !== runsDir) {
803
+ throw new ReleaseError(
804
+ GATE_FAILED,
805
+ 'distribute-* candidate real path is not contained in authority runs directory',
806
+ { candidateDir, candidateReal, runsDir },
807
+ );
808
+ }
809
+ const candidatePath = resolve(candidateDir, 'release-run.json');
810
+ try {
811
+ const candidate = await loadRun(candidatePath, { requireDigest: true });
812
+ if (candidate.command !== 'distribute') continue;
813
+ if (!candidate.planDigest) continue;
814
+ if (candidate.planDigest !== plan.digest) continue;
815
+ candidates.push(candidate);
816
+ } catch {
817
+ // Skip invalid candidates silently
818
+ continue;
819
+ }
820
+ }
821
+
822
+ return candidates;
823
+ }
824
+
728
825
  // ---------------------------------------------------------------------------
729
826
  // Public API
730
827
  // ---------------------------------------------------------------------------
@@ -758,6 +855,8 @@ export async function verifyRelease(options) {
758
855
  verificationGatesAuthorized: _verificationGatesAuthorized,
759
856
  gateEnv,
760
857
  previousVerifyRun,
858
+ execFn,
859
+ configPath: configPathOpt,
761
860
  } = options ?? {};
762
861
 
763
862
  const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
@@ -866,6 +965,32 @@ export async function verifyRelease(options) {
866
965
  );
867
966
  }
868
967
 
968
+ // =======================================================================
969
+ // Step 2b: Check for postPublish distribution requirement
970
+ // =======================================================================
971
+ if (plan.postPublish && plan.postPublish.targets && plan.postPublish.targets.length > 0) {
972
+ await evidence.append({ phase: 'verify', step: 'distribute-run-discovery', status: 'started' });
973
+
974
+ const distributeCandidates = await discoverDistributeRuns({ planPath, plan });
975
+ const distRunPath = (distributeCandidates ?? []).find((c) => c.status === 'DISTRIBUTED')?.runPath || null;
976
+
977
+ await evidence.append({
978
+ phase: 'verify',
979
+ step: 'distribute-run-discovery',
980
+ status: 'checked',
981
+ foundCandidate: !!distRunPath,
982
+ distributeRunPath: distRunPath,
983
+ });
984
+
985
+ if (!distRunPath) {
986
+ throw new ReleaseError(
987
+ GATE_FAILED,
988
+ `distribution required by plan.postPublish but no DISTRIBUTED run found; run release-skill distribute --plan ${planPath} --root ${root}`,
989
+ { requiredDistribution: true, evidenceEvent: 'distribute-run-missing' },
990
+ );
991
+ }
992
+ }
993
+
869
994
  if (plan.production) {
870
995
  if (!sourceRun.approvalPath || !sourceRun.approvalDigest) {
871
996
  throw new ReleaseError(
@@ -1678,6 +1803,78 @@ export async function verifyRelease(options) {
1678
1803
  };
1679
1804
  const persistedVerifyRun = await writeRunAtomic(verifyRunPath, verifyRunState);
1680
1805
 
1806
+ // =======================================================================
1807
+ // FROZEN marker retirement (2026-08-18 investigation §4.5 option 1):
1808
+ // the release reached VERIFIED, so the freeze signal is cleared. Only
1809
+ // this success path clears it — failed/PARTIAL verify runs never reach
1810
+ // here and keep the marker. Best-effort: a removal failure must never
1811
+ // demote VERIFIED.
1812
+ // =======================================================================
1813
+ try {
1814
+ const removed = await clearFrozenMarker(join(root, '.release-skill'));
1815
+ await evidence.append({
1816
+ phase: 'frozen-marker',
1817
+ status: removed ? 'cleared' : 'absent',
1818
+ });
1819
+ } catch (err) {
1820
+ await evidence.append({
1821
+ phase: 'frozen-marker',
1822
+ status: 'clear-failed',
1823
+ error: { code: err.code, message: err.message },
1824
+ });
1825
+ }
1826
+
1827
+ // =======================================================================
1828
+ // Baseline advance: move per-unit previousPublicBaseline to the commit
1829
+ // that was just verified. Local bookkeeping only — no remote writes.
1830
+ // Best-effort: a failure here must never demote VERIFIED, and the config
1831
+ // is only auto-committed when the file was clean before the write, so a
1832
+ // dirty worktree leaves the decision to the human.
1833
+ // =======================================================================
1834
+ let baselineAdvance = null;
1835
+ const baselineAdvances = deriveBaselineAdvances(plan);
1836
+ if (baselineAdvances.length > 0) {
1837
+ try {
1838
+ const configAbs = configPathOpt
1839
+ ? (isAbsolute(configPathOpt) ? configPathOpt : join(root, configPathOpt))
1840
+ : join(root, '.release-skill/project.yaml');
1841
+ const cleanBefore = await isWorktreeFileClean({ root, filePath: configAbs, execFn });
1842
+ const applyResult = await applyBaselineAdvances({
1843
+ configPath: configAbs,
1844
+ advances: baselineAdvances,
1845
+ validateFn: async () => { await loadProjectConfig({ root, configPath: configAbs }); },
1846
+ });
1847
+ baselineAdvance = { ...applyResult, committed: false };
1848
+ if (applyResult.changed && cleanBefore) {
1849
+ const versionLabel = (plan.units ?? [])
1850
+ .map((u) => `${u.id} v${u.targetVersion}`)
1851
+ .join(', ');
1852
+ await commitBaselineAdvance({
1853
+ root,
1854
+ filePath: configAbs,
1855
+ message: `chore: advance previousPublicBaseline to published ${versionLabel}`,
1856
+ execFn,
1857
+ });
1858
+ baselineAdvance.committed = true;
1859
+ }
1860
+ await evidence.append({
1861
+ phase: 'verify',
1862
+ step: 'baseline-advance',
1863
+ status: applyResult.changed ? 'advanced' : 'already-current',
1864
+ updatedUnits: applyResult.updatedUnits,
1865
+ committed: baselineAdvance.committed,
1866
+ });
1867
+ } catch (err) {
1868
+ baselineAdvance = { failed: true, error: err.message };
1869
+ await evidence.append({
1870
+ phase: 'verify',
1871
+ step: 'baseline-advance',
1872
+ status: 'failed',
1873
+ error: { code: err.code, message: err.message },
1874
+ });
1875
+ }
1876
+ }
1877
+
1681
1878
  await evidence.finish({
1682
1879
  status: VERIFIED,
1683
1880
  planPath,
@@ -1697,6 +1894,7 @@ export async function verifyRelease(options) {
1697
1894
  smokeTest,
1698
1895
  gateResults: consumerGateResults,
1699
1896
  ...(manualFollowUps.length > 0 ? { manualFollowUps } : {}),
1897
+ ...(baselineAdvance ? { baselineAdvance } : {}),
1700
1898
  };
1701
1899
  } catch (err) {
1702
1900
  await evidence.append({
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Baseline advance: after a release reaches VERIFIED, the project config's
3
+ * per-unit `previousPublicBaseline` must be advanced from the previous
4
+ * public commit to the commit that was just published. Doing this by hand
5
+ * is the most error-prone step in the release loop, so it is derived from
6
+ * the frozen plan (never recomputed from workspace state) and written back
7
+ * into `.release-skill/project.yaml` with comment preservation.
8
+ *
9
+ * Fail-safe by construction: any unit whose plan data is missing or
10
+ * malformed is skipped rather than guessed.
11
+ */
12
+ import { readFile, writeFile } from 'node:fs/promises';
13
+ import { execFile as execFileCb } from 'node:child_process';
14
+ import { promisify } from 'node:util';
15
+ import YAML from 'yaml';
16
+ import { ReleaseError, GATE_FAILED } from './errors.mjs';
17
+
18
+ const execFileAsync = promisify(execFileCb);
19
+
20
+ function defaultExec(command, args, options = {}) {
21
+ return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: 120_000, ...options });
22
+ }
23
+
24
+ const SHA_RE = /^[a-f0-9]{40}$/;
25
+ const DIGEST_RE = /^[a-f0-9]{64}$/;
26
+
27
+ /**
28
+ * Derive the baseline advances that a VERIFIED plan implies.
29
+ *
30
+ * For each unit with a `mode: bound` previousPublicBaseline, the published
31
+ * commit/tree/manifestDigest come from the unit's frozen push-snapshot
32
+ * action — the same values that were pushed to the public repo.
33
+ *
34
+ * @param {{ units?: Array<Record<string, unknown>>, externalActions?: Array<Record<string, unknown>> }} plan
35
+ * The frozen release plan.
36
+ * @returns {Array<{ unitId: string, repo: string, previousCommit: string, commit: string, tree: string, manifestDigest: string }>}
37
+ * One advance per unit that is behind its published commit. Units already
38
+ * at the published commit (idempotent re-runs) produce no advance.
39
+ */
40
+ export function deriveBaselineAdvances(plan) {
41
+ const advances = [];
42
+ for (const unit of plan?.units ?? []) {
43
+ const baseline = unit?.previousPublicBaseline;
44
+ if (!baseline || baseline.mode !== 'bound') continue;
45
+
46
+ const action = (plan?.externalActions ?? []).find((a) => (
47
+ a?.type === 'push-snapshot'
48
+ && a?.parameters?.publicRepo === unit.publicRepo
49
+ ));
50
+ const { commit, tree, manifestDigest } = action?.parameters ?? {};
51
+
52
+ if (!SHA_RE.test(commit ?? '') || !SHA_RE.test(tree ?? '') || !DIGEST_RE.test(manifestDigest ?? '')) {
53
+ continue;
54
+ }
55
+ if (baseline.commit === commit) continue;
56
+
57
+ advances.push({
58
+ unitId: unit.id,
59
+ repo: baseline.repo,
60
+ previousCommit: baseline.commit,
61
+ commit,
62
+ tree,
63
+ manifestDigest,
64
+ });
65
+ }
66
+ return advances;
67
+ }
68
+
69
+ /**
70
+ * Apply derived advances to the project config file, preserving comments and
71
+ * unrelated content by editing the YAML AST in place. When a `validateFn` is
72
+ * provided, the rewritten file is re-validated and the original content is
73
+ * restored if validation fails (the advance must never corrupt the config).
74
+ *
75
+ * @param {{ configPath: string, advances: Array<Record<string, unknown>>, validateFn?: (configPath: string) => Promise<void> }} opts
76
+ * @returns {Promise<{ changed: boolean, updatedUnits: string[] }>}
77
+ */
78
+ export async function applyBaselineAdvances({ configPath, advances, validateFn }) {
79
+ if (!advances || advances.length === 0) {
80
+ return { changed: false, updatedUnits: [] };
81
+ }
82
+
83
+ let content;
84
+ try {
85
+ content = await readFile(configPath, 'utf8');
86
+ } catch (err) {
87
+ throw new ReleaseError(
88
+ GATE_FAILED,
89
+ `baseline advance cannot read project config: ${err.message}`,
90
+ { configPath, cause: err.code },
91
+ );
92
+ }
93
+
94
+ const doc = YAML.parseDocument(content);
95
+ const releaseUnits = doc.get('releaseUnits', true);
96
+ if (!YAML.isSeq(releaseUnits)) {
97
+ throw new ReleaseError(
98
+ GATE_FAILED,
99
+ 'baseline advance requires a releaseUnits sequence in the project config',
100
+ { configPath },
101
+ );
102
+ }
103
+
104
+ const updatedUnits = [];
105
+ for (const advance of advances) {
106
+ const unitNode = releaseUnits.items.find((item) => (
107
+ YAML.isMap(item) && item.get('id') === advance.unitId
108
+ ));
109
+ if (!unitNode) continue;
110
+
111
+ let baselineNode = unitNode.get('previousPublicBaseline', true);
112
+ if (!YAML.isMap(baselineNode)) {
113
+ baselineNode = new YAML.YAMLMap();
114
+ unitNode.set('previousPublicBaseline', baselineNode);
115
+ }
116
+
117
+ let changed = false;
118
+ const setField = (key, value) => {
119
+ if (baselineNode.get(key) !== value) {
120
+ baselineNode.set(key, value);
121
+ changed = true;
122
+ }
123
+ };
124
+ setField('commit', advance.commit);
125
+ setField('tree', advance.tree);
126
+ setField('manifestDigest', advance.manifestDigest);
127
+
128
+ if (changed) updatedUnits.push(advance.unitId);
129
+ }
130
+
131
+ if (updatedUnits.length === 0) {
132
+ return { changed: false, updatedUnits: [] };
133
+ }
134
+
135
+ const next = doc.toString();
136
+ await writeFile(configPath, next, 'utf8');
137
+
138
+ if (typeof validateFn === 'function') {
139
+ try {
140
+ await validateFn(configPath);
141
+ } catch (err) {
142
+ // Restore the original content so a bad advance never corrupts config.
143
+ await writeFile(configPath, content, 'utf8');
144
+ throw new ReleaseError(
145
+ GATE_FAILED,
146
+ `baseline advance reverted: rewritten config failed validation: ${err.message}`,
147
+ { configPath, cause: err.message },
148
+ );
149
+ }
150
+ }
151
+
152
+ return { changed: true, updatedUnits };
153
+ }
154
+
155
+ /**
156
+ * Report whether a single file is clean in the git worktree (no staged or
157
+ * unstaged changes). Fails safe to `false` on any git error so that a
158
+ * non-repository or a broken git never authorizes an automatic commit.
159
+ *
160
+ * @param {{ root: string, filePath: string, execFn?: Function }} opts
161
+ * @returns {Promise<boolean>}
162
+ */
163
+ export async function isWorktreeFileClean({ root, filePath, execFn }) {
164
+ const exec = typeof execFn === 'function' ? execFn : defaultExec;
165
+ try {
166
+ const { stdout } = await exec('git', ['status', '--porcelain', '--', filePath], { cwd: root });
167
+ return stdout.trim() === '';
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Stage and commit only the config file. The caller is responsible for
175
+ * checking `isWorktreeFileClean` *before* writing so that this commit never
176
+ * sweeps in unrelated in-flight work.
177
+ *
178
+ * @param {{ root: string, filePath: string, message: string, execFn?: Function }} opts
179
+ * @returns {Promise<void>}
180
+ */
181
+ export async function commitBaselineAdvance({ root, filePath, message, execFn }) {
182
+ const exec = typeof execFn === 'function' ? execFn : defaultExec;
183
+ await exec('git', ['add', '--', filePath], { cwd: root });
184
+ await exec('git', ['commit', '-m', message, '--', filePath], { cwd: root });
185
+ }