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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +27 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +15 -14
- package/README.zh-CN.md +16 -15
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +3779 -769
- package/adapters/claude/schemas/.render-manifest.json +6 -6
- package/adapters/claude/schemas/release-plan.schema.json +401 -2
- package/adapters/claude/schemas/release-project.schema.json +299 -0
- package/adapters/claude/schemas/release-run.schema.json +59 -6
- package/adapters/claude/skills/release-config/SKILL.md +140 -0
- package/adapters/claude/skills/release-docs/SKILL.md +106 -0
- package/adapters/claude/skills/release-help/SKILL.md +39 -0
- package/adapters/claude/skills/release-marketplace/SKILL.md +147 -0
- package/adapters/claude/skills/release-publish/SKILL.md +25 -6
- package/adapters/claude/skills/release-verify/SKILL.md +10 -0
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +3779 -769
- package/adapters/codex/schemas/.render-manifest.json +6 -6
- package/adapters/codex/schemas/release-plan.schema.json +401 -2
- package/adapters/codex/schemas/release-project.schema.json +299 -0
- package/adapters/codex/schemas/release-run.schema.json +59 -6
- package/adapters/codex/skills/release-config/SKILL.md +147 -0
- package/adapters/codex/skills/release-docs/SKILL.md +113 -0
- package/adapters/codex/skills/release-help/SKILL.md +39 -0
- package/adapters/codex/skills/release-marketplace/SKILL.md +154 -0
- package/adapters/codex/skills/release-publish/SKILL.md +25 -6
- package/adapters/codex/skills/release-verify/SKILL.md +10 -0
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +3779 -769
- package/adapters/kimi/schemas/.render-manifest.json +6 -6
- package/adapters/kimi/schemas/release-plan.schema.json +401 -2
- package/adapters/kimi/schemas/release-project.schema.json +299 -0
- package/adapters/kimi/schemas/release-run.schema.json +59 -6
- package/adapters/kimi/skills/release-config/SKILL.md +147 -0
- package/adapters/kimi/skills/release-docs/SKILL.md +113 -0
- package/adapters/kimi/skills/release-help/SKILL.md +39 -0
- package/adapters/kimi/skills/release-marketplace/SKILL.md +154 -0
- package/adapters/kimi/skills/release-publish/SKILL.md +25 -6
- package/adapters/kimi/skills/release-verify/SKILL.md +10 -0
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +3779 -769
- package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
- package/adapters/workbuddy/schemas/release-plan.schema.json +401 -2
- package/adapters/workbuddy/schemas/release-project.schema.json +299 -0
- package/adapters/workbuddy/schemas/release-run.schema.json +59 -6
- package/adapters/workbuddy/skills/release-config/SKILL.md +140 -0
- package/adapters/workbuddy/skills/release-docs/SKILL.md +106 -0
- package/adapters/workbuddy/skills/release-help/SKILL.md +39 -0
- package/adapters/workbuddy/skills/release-marketplace/SKILL.md +147 -0
- package/adapters/workbuddy/skills/release-publish/SKILL.md +25 -6
- package/adapters/workbuddy/skills/release-verify/SKILL.md +10 -0
- package/bin/release-skill-cli.mjs +246 -7
- package/bin/release-skill.bundle.mjs +3779 -769
- package/package.json +3 -2
- package/platform-manifest.json +359 -0
- package/references/.render-manifest.json +9 -9
- package/references/02-project-config.md +35 -0
- package/references/05-evidence-and-errors.md +45 -0
- package/references/06-adapter-contract.md +13 -0
- package/schemas/.render-manifest.json +6 -6
- package/schemas/release-plan.schema.json +401 -2
- package/schemas/release-project.schema.json +299 -0
- package/schemas/release-run.schema.json +59 -6
- package/scripts/build-bundle.mjs +11 -2
- package/scripts/sync-public-files.mjs +4 -0
- package/skills/release-config/SKILL.md +140 -0
- package/skills/release-docs/SKILL.md +106 -0
- package/skills/release-help/SKILL.md +39 -0
- package/skills/release-marketplace/SKILL.md +147 -0
- package/skills/release-publish/SKILL.md +25 -6
- package/skills/release-verify/SKILL.md +10 -0
- package/skills-src/release-config/SKILL.md +140 -0
- package/skills-src/release-docs/SKILL.md +106 -0
- package/skills-src/release-help/SKILL.md +39 -0
- package/skills-src/release-marketplace/SKILL.md +147 -0
- package/skills-src/release-publish/SKILL.md +25 -6
- package/skills-src/release-verify/SKILL.md +10 -0
- package/src/adapters/contract.mjs +5 -0
- package/src/adapters/distribute-git.mjs +625 -0
- package/src/commands/distribute.mjs +1078 -0
- package/src/commands/lineage.mjs +616 -0
- package/src/commands/prepare.mjs +548 -83
- package/src/commands/route.mjs +686 -0
- package/src/commands/ship.mjs +45 -1
- package/src/commands/verify.mjs +198 -0
- package/src/core/baseline-advance.mjs +185 -0
- package/src/core/bundle-freshness.mjs +236 -0
- package/src/core/checkpoints.mjs +25 -0
- package/src/core/errors.mjs +2 -0
- package/src/core/frozen-marker.mjs +97 -0
- package/src/core/hooks.mjs +12 -1
- package/src/core/postpublish.mjs +315 -0
package/src/commands/ship.mjs
CHANGED
|
@@ -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);
|
package/src/commands/verify.mjs
CHANGED
|
@@ -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
|
+
}
|