release-skill 0.9.12 → 0.9.14

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 (44) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codebuddy-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.kimi-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +45 -0
  6. package/INSTALL.md +2 -2
  7. package/INSTALL.zh-CN.md +2 -2
  8. package/README.md +39 -15
  9. package/README.zh-CN.md +29 -12
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +1141 -694
  12. package/adapters/claude/schemas/release-plan.schema.json +41 -1
  13. package/adapters/claude/schemas/release-project.schema.json +41 -0
  14. package/adapters/claude/skills/release-finish/SKILL.md +1 -1
  15. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  16. package/adapters/codex/bin/release-skill.bundle.mjs +1141 -694
  17. package/adapters/codex/schemas/release-plan.schema.json +41 -1
  18. package/adapters/codex/schemas/release-project.schema.json +41 -0
  19. package/adapters/codex/skills/release-finish/SKILL.md +1 -1
  20. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  21. package/adapters/kimi/bin/release-skill.bundle.mjs +1141 -694
  22. package/adapters/kimi/schemas/release-plan.schema.json +41 -1
  23. package/adapters/kimi/schemas/release-project.schema.json +41 -0
  24. package/adapters/kimi/skills/release-finish/SKILL.md +1 -1
  25. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  26. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1141 -694
  27. package/adapters/workbuddy/schemas/release-plan.schema.json +41 -1
  28. package/adapters/workbuddy/schemas/release-project.schema.json +41 -0
  29. package/adapters/workbuddy/skills/release-finish/SKILL.md +1 -1
  30. package/bin/release-skill-cli.mjs +26 -10
  31. package/bin/release-skill.bundle.mjs +1141 -694
  32. package/package.json +1 -1
  33. package/platform-manifest.json +4 -4
  34. package/references/02-project-config.md +10 -0
  35. package/references/05-evidence-and-errors.md +2 -1
  36. package/schemas/release-plan.schema.json +41 -1
  37. package/schemas/release-project.schema.json +41 -0
  38. package/skills/release-finish/SKILL.md +1 -1
  39. package/skills-src/release-finish/SKILL.md +1 -1
  40. package/src/commands/post-release-local.mjs +73 -3
  41. package/src/commands/prepare.mjs +56 -0
  42. package/src/core/postpublish.mjs +51 -0
  43. package/src/core/run-retention.mjs +227 -0
  44. package/src/core/run.mjs +10 -1
@@ -0,0 +1,227 @@
1
+ /** Best-effort cleanup of superseded, complete production run lineages. */
2
+
3
+ import { lstat, readFile, readdir, realpath, rm as fsRm } from 'node:fs/promises';
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
+ import { resolveContained } from 'skill-family-harness-node';
6
+ import { assertImmutablePlanAuthority, computePlanDigest, validatePlan } from './plan.mjs';
7
+ import { loadRun, validateRunCheckpointMapping, validateRunLineage, validateRunPlanDigest } from './run.mjs';
8
+ import { normalizePostPublishView, postPublishActionId } from './postpublish.mjs';
9
+
10
+ const NON_TERMINAL = new Set(['PUBLISHING', 'PARTIAL', 'NEEDS_INPUT', 'BLOCKED', 'DISTRIBUTING']);
11
+ const COMPLETED_CHECKPOINTS = new Set(['succeeded', 'skipped']);
12
+
13
+ function validDirectName(name) {
14
+ return typeof name === 'string' && name.length > 0 && name !== '.' && name !== '..'
15
+ && !isAbsolute(name) && !name.includes('/') && !name.includes('\\');
16
+ }
17
+
18
+ async function jsonFile(path) {
19
+ try { return JSON.parse(await readFile(path, 'utf8')); } catch { return null; }
20
+ }
21
+
22
+ async function physicalContained(root, declaredPath) {
23
+ if (typeof declaredPath !== 'string' || !isAbsolute(declaredPath)) return null;
24
+ const lexicalRoot = resolve(root);
25
+ const physicalRoot = await realpath(lexicalRoot);
26
+ const lexical = relative(lexicalRoot, resolve(declaredPath));
27
+ if (!lexical || isAbsolute(lexical) || lexical === '..' || lexical.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) return null;
28
+ try {
29
+ const contained = await resolveContained(lexicalRoot, lexical);
30
+ const target = await realpath(contained);
31
+ const targetRel = relative(physicalRoot, target);
32
+ if (!targetRel || isAbsolute(targetRel) || targetRel === '..' || targetRel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) return null;
33
+ return target;
34
+ } catch { return null; }
35
+ }
36
+
37
+ async function loadPlanFromRun(releaseDir, run) {
38
+ if (!run?.planPath) return null;
39
+ const planPath = await physicalContained(releaseDir, run.planPath);
40
+ if (!planPath || basename(dirname(planPath)) !== 'plans') return null;
41
+ const plan = await jsonFile(planPath);
42
+ if (!plan) return null;
43
+ try {
44
+ validatePlan(plan);
45
+ assertImmutablePlanAuthority(planPath, plan);
46
+ const digest = computePlanDigest(plan);
47
+ if (run.planDigest !== digest || basename(planPath) !== `${digest}.json`) return null;
48
+ return { plan, planPath, digest };
49
+ } catch { return null; }
50
+ }
51
+
52
+ function postVerifyActions(plan) {
53
+ return normalizePostPublishView(plan).flatMap((declaration) => (
54
+ (declaration.hooks ?? []).filter((hook) => hook.phase === 'postVerify').map((hook) => ({
55
+ id: postPublishActionId({ planVersion: plan.planVersion, unitId: declaration.unitId, localId: hook.id }),
56
+ type: 'postpublish-hook',
57
+ }))
58
+ ));
59
+ }
60
+
61
+ function postVerificationComplete(run) {
62
+ return run.checkpoints.every((checkpoint) => (
63
+ checkpoint.status === 'succeeded' || checkpoint.status === 'NO_CHANGE'
64
+ ));
65
+ }
66
+
67
+ function publicationComplete(run) {
68
+ return run.checkpoints.every((cp) => COMPLETED_CHECKPOINTS.has(cp.status)
69
+ || (['failed', 'deferred'].includes(cp.status)
70
+ && ['claude-marketplace-install', 'codex-marketplace-install', 'kimi-marketplace-install', 'codebuddy-marketplace-install'].includes(cp.actionType)));
71
+ }
72
+
73
+ async function lineagePathsContained(run, runsRoot) {
74
+ let cursor = run;
75
+ for (let depth = 0; depth < 32; depth += 1) {
76
+ if (!cursor.sourceRunPath) return true;
77
+ const sourcePath = await physicalContained(runsRoot, cursor.sourceRunPath);
78
+ if (!sourcePath) return false;
79
+ cursor = await loadRun(sourcePath, { requireDigest: true });
80
+ }
81
+ return false;
82
+ }
83
+
84
+ async function validatePublicationCheckpoints(run, plan, runsRoot) {
85
+ let cursor = run;
86
+ for (let depth = 0; depth < 32; depth += 1) {
87
+ if (cursor.command === 'publish') {
88
+ validateRunCheckpointMapping(cursor, plan.externalActions ?? []);
89
+ return cursor.status === 'PUBLISHED' ? publicationComplete(cursor) : true;
90
+ }
91
+ if (!cursor.sourceRunPath) return false;
92
+ const sourcePath = await physicalContained(runsRoot, cursor.sourceRunPath);
93
+ if (!sourcePath) return false;
94
+ cursor = await loadRun(sourcePath, { requireDigest: true });
95
+ if (cursor.command === 'reconcile') validateRunCheckpointMapping(cursor, plan.externalActions ?? []);
96
+ }
97
+ return false;
98
+ }
99
+
100
+ async function validateTerminal({ candidate, planInfo, runsRoot }) {
101
+ const { run, runPath } = candidate;
102
+ if (!run || !planInfo) return false;
103
+ const { plan, planPath } = planInfo;
104
+ try {
105
+ if (!(await lineagePathsContained(run, runsRoot))) return false;
106
+ await validateRunLineage(run, { plan, planPath, runPath, production: Boolean(plan.production) });
107
+ validateRunPlanDigest(run, plan, { planPath });
108
+ if (run.command === 'verify' && run.status === 'VERIFIED') {
109
+ return validatePublicationCheckpoints(run, plan, runsRoot);
110
+ }
111
+ if (run.command !== 'postverify' || run.status !== 'DISTRIBUTED') return false;
112
+ validateRunCheckpointMapping(run, postVerifyActions(plan));
113
+ return postVerificationComplete(run)
114
+ && validatePublicationCheckpoints(run, plan, runsRoot);
115
+ } catch { return false; }
116
+ }
117
+
118
+ async function scanCandidate(runsRoot, name) {
119
+ const dir = join(runsRoot, name);
120
+ const summary = await jsonFile(join(dir, 'summary.json'));
121
+ const runPath = join(dir, 'release-run.json');
122
+ let run = null;
123
+ let runExists = false;
124
+ let runCorrupt = false;
125
+ try {
126
+ const stat = await lstat(runPath);
127
+ runExists = true;
128
+ if (stat.isSymbolicLink() || !stat.isFile()) runCorrupt = true;
129
+ else run = await loadRun(runPath, { requireDigest: true });
130
+ } catch { if (runExists) runCorrupt = true; }
131
+ const statesExists = await lstat(join(dir, 'states')).then(() => true).catch(() => false);
132
+ let sealedFailedPrepare = false;
133
+ if (summary?.status === 'FAILED' && !runExists) {
134
+ try {
135
+ const evidence = await readFile(join(dir, 'evidence.jsonl'), 'utf8');
136
+ sealedFailedPrepare = evidence.split('\n').some((line) => JSON.parse(line)?.command === 'prepare');
137
+ } catch { sealedFailedPrepare = false; }
138
+ }
139
+ return {
140
+ dir, name, summary, run, runPath, runExists, runCorrupt, statesExists, sealedFailedPrepare,
141
+ planDigest: run?.planDigest ?? summary?.planDigest ?? null,
142
+ };
143
+ }
144
+
145
+ async function deleteCandidate(runsRoot, candidate, rmFn) {
146
+ const contained = await physicalContained(runsRoot, join(runsRoot, candidate.name));
147
+ const physicalRoot = await realpath(runsRoot);
148
+ if (!contained || dirname(contained) !== physicalRoot) throw new Error('run path containment failed');
149
+ const stat = await lstat(contained);
150
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('run candidate is not a real directory');
151
+ await rmFn(contained, { recursive: true, force: false });
152
+ }
153
+
154
+ /** @returns {Promise<{scanned:number,deleted:number,errors:number,diagnostics:Array}>} */
155
+ export async function cleanupRunRetention({ releaseDir, currentRunDir, rmFn = fsRm } = {}) {
156
+ const result = { scanned: 0, deleted: 0, errors: 0, diagnostics: [] };
157
+ if (!releaseDir) return result;
158
+ const runsRoot = resolve(releaseDir, 'runs');
159
+ let rootStat;
160
+ try { rootStat = await lstat(runsRoot); } catch { return result; }
161
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return result;
162
+ const physicalRunsRoot = await realpath(runsRoot).catch(() => null);
163
+ if (!physicalRunsRoot) return result;
164
+ let entries;
165
+ try { entries = await readdir(physicalRunsRoot, { withFileTypes: true }); } catch { return result; }
166
+ const candidates = [];
167
+ for (const entry of entries) {
168
+ result.scanned += 1;
169
+ if (!entry.isDirectory() || entry.isSymbolicLink() || !validDirectName(entry.name)) continue;
170
+ candidates.push(await scanCandidate(physicalRunsRoot, entry.name));
171
+ }
172
+ const protectedNames = new Set();
173
+ if (currentRunDir) protectedNames.add(basename(resolve(currentRunDir)));
174
+ const currentPlan = await jsonFile(join(releaseDir, 'release-plan.json'));
175
+ let currentPlanDigest = null;
176
+ try { if (currentPlan) currentPlanDigest = computePlanDigest(currentPlan); } catch { currentPlanDigest = null; }
177
+ const groups = new Map();
178
+ for (const candidate of candidates) {
179
+ if (!candidate.planDigest) continue;
180
+ if (!groups.has(candidate.planDigest)) groups.set(candidate.planDigest, []);
181
+ groups.get(candidate.planDigest).push(candidate);
182
+ if (currentPlanDigest && candidate.planDigest === currentPlanDigest) protectedNames.add(candidate.name);
183
+ }
184
+ const completeGroups = [];
185
+ for (const [planDigest, group] of groups) {
186
+ const authority = group.find((item) => item.run?.planDigest === planDigest);
187
+ const planInfo = authority ? await loadPlanFromRun(releaseDir, authority.run) : null;
188
+ const requiresPostVerify = Boolean(planInfo && normalizePostPublishView(planInfo.plan)
189
+ .some((declaration) => (declaration.hooks ?? []).some((hook) => hook.phase === 'postVerify')));
190
+ const terminals = group.filter((item) => requiresPostVerify
191
+ ? item.run?.status === 'DISTRIBUTED'
192
+ : item.run?.status === 'VERIFIED');
193
+ const terminalResults = planInfo
194
+ ? await Promise.all(terminals.map((item) => validateTerminal({ candidate: item, planInfo, runsRoot })))
195
+ : [];
196
+ const terminalValid = terminalResults.some(Boolean);
197
+ const unsafe = group.some((item) => item.runCorrupt
198
+ || (!item.run && item.summary?.status !== 'PREPARED')
199
+ || (item.run && NON_TERMINAL.has(item.run.status))
200
+ || (item.run?.status === 'PUBLISHED' && !publicationComplete(item.run))
201
+ || (item.run && ((requiresPostVerify && item.run.status === 'DISTRIBUTED')
202
+ || (!requiresPostVerify && item.run.status === 'VERIFIED'))
203
+ && !terminalResults[terminals.indexOf(item)]));
204
+ if (!terminalValid || unsafe) {
205
+ for (const item of group) protectedNames.add(item.name);
206
+ } else {
207
+ completeGroups.push({ group, createdAt: planInfo.plan.createdAt ?? '', planDigest });
208
+ }
209
+ }
210
+ completeGroups.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)) || b.planDigest.localeCompare(a.planDigest));
211
+ for (const old of completeGroups.slice(1)) {
212
+ for (const item of old.group) {
213
+ if (protectedNames.has(item.name)) continue;
214
+ try { await deleteCandidate(physicalRunsRoot, item, rmFn); result.deleted += 1; }
215
+ catch (error) { result.errors += 1; result.diagnostics.push({ name: item.name, code: error?.code ?? 'RUN_RETENTION_DELETE_FAILED' }); }
216
+ }
217
+ }
218
+ for (const candidate of candidates) {
219
+ const hasPlanTrace = Boolean(candidate.planDigest || candidate.summary?.planPath || candidate.summary?.externalActions || candidate.summary?.checkpoints);
220
+ const isFailedPrepare = candidate.name.startsWith('prepare-') && candidate.summary?.status === 'FAILED'
221
+ && candidate.sealedFailedPrepare && !candidate.runExists && !candidate.statesExists && !hasPlanTrace;
222
+ if (!isFailedPrepare || protectedNames.has(candidate.name)) continue;
223
+ try { await deleteCandidate(physicalRunsRoot, candidate, rmFn); result.deleted += 1; }
224
+ catch (error) { result.errors += 1; result.diagnostics.push({ name: candidate.name, code: error?.code ?? 'RUN_RETENTION_DELETE_FAILED' }); }
225
+ }
226
+ return result;
227
+ }
package/src/core/run.mjs CHANGED
@@ -484,6 +484,15 @@ function validateSourceRunEdge(child, parent) {
484
484
  );
485
485
  }
486
486
  }
487
+ if (child.command === 'postverify') {
488
+ if (parent.command !== 'verify' || parent.status !== 'VERIFIED') {
489
+ throw new ReleaseError(
490
+ GATE_FAILED,
491
+ 'postverify lineage must reference a VERIFIED verify run',
492
+ { childCommand: child.command, parentCommand: parent.command, parentStatus: parent.status },
493
+ );
494
+ }
495
+ }
487
496
  }
488
497
 
489
498
  /**
@@ -501,7 +510,7 @@ export async function validateRunLineage(run, options = {}) {
501
510
  await validateStatePredecessorChain(run, runPath, { production, planPath });
502
511
  validateRunPlanDigest(run, plan, { planPath });
503
512
  if (run.command === 'publish') return;
504
- if (!['reconcile', 'verify'].includes(run.command)) {
513
+ if (!['reconcile', 'verify', 'postverify'].includes(run.command)) {
505
514
  throw new ReleaseError(GATE_FAILED, `unsupported run command in lineage: ${run.command}`);
506
515
  }
507
516
  if (!run.sourceRunPath || !run.sourceRunId || !run.sourceRunDigest) {