release-skill 0.6.2 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +23 -0
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +47 -2
- package/INSTALL.zh-CN.md +29 -2
- package/README.md +126 -9
- package/README.zh-CN.md +108 -9
- 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 +6408 -1654
- package/adapters/claude/schemas/.render-manifest.json +10 -6
- package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/claude/schemas/release-plan.schema.json +65 -1
- package/adapters/claude/schemas/release-project.schema.json +73 -1
- package/adapters/claude/schemas/release-run.schema.json +11 -6
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +6408 -1654
- package/adapters/codex/schemas/.render-manifest.json +10 -6
- package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/codex/schemas/release-plan.schema.json +65 -1
- package/adapters/codex/schemas/release-project.schema.json +73 -1
- package/adapters/codex/schemas/release-run.schema.json +11 -6
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +6408 -1654
- package/adapters/kimi/schemas/.render-manifest.json +10 -6
- package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/kimi/schemas/release-plan.schema.json +65 -1
- package/adapters/kimi/schemas/release-project.schema.json +73 -1
- package/adapters/kimi/schemas/release-run.schema.json +11 -6
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +6408 -1654
- package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
- package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/workbuddy/schemas/release-plan.schema.json +65 -1
- package/adapters/workbuddy/schemas/release-project.schema.json +73 -1
- package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
- package/bin/release-skill-cli.mjs +181 -3
- package/bin/release-skill.bundle.mjs +6408 -1654
- package/package.json +2 -1
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +5 -5
- package/references/01-state-machine.md +22 -2
- package/schemas/.render-manifest.json +10 -6
- package/schemas/postpublish-approval-record.schema.json +47 -0
- package/schemas/release-plan.schema.json +65 -1
- package/schemas/release-project.schema.json +73 -1
- package/schemas/release-run.schema.json +11 -6
- package/src/commands/approve.mjs +167 -1
- package/src/commands/distribute.mjs +411 -33
- package/src/commands/postverify.mjs +734 -0
- package/src/commands/prepare.mjs +280 -42
- package/src/commands/setup.mjs +715 -0
- package/src/commands/ship.mjs +152 -5
- package/src/commands/verify.mjs +92 -15
- package/src/core/approval.mjs +93 -68
- package/src/core/bounded-output.mjs +46 -0
- package/src/core/derived-artifact-gates.mjs +258 -0
- package/src/core/docs-refresh-preset.mjs +167 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/hooks.mjs +28 -0
- package/src/core/marketplace-registry-entry.mjs +174 -0
- package/src/core/notify-handoff.mjs +76 -0
- package/src/core/postpublish-approval.mjs +110 -0
- package/src/core/postpublish.mjs +424 -7
- package/src/core/preset-executor.mjs +156 -0
- package/src/core/preset-gitwrite.mjs +463 -0
- package/src/core/presets.mjs +706 -0
- package/src/core/proposal-inbox.mjs +630 -0
- package/src/core/run.mjs +91 -6
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* postVerify command: independent run routed AFTER the main run is VERIFIED
|
|
3
|
+
* (v0.6.3 R3, design §2.4).
|
|
4
|
+
*
|
|
5
|
+
* `postVerifyRelease` produces an INDEPENDENT run record (reused run +
|
|
6
|
+
* checkpoint machinery) whose lineage source is the VERIFIED verify run:
|
|
7
|
+
*
|
|
8
|
+
* - hooks receive the §2.3 context with `verifyEvidence` PRESENT (distribute
|
|
9
|
+
* phase contexts never carry it); `publishedAt` comes from the sealed
|
|
10
|
+
* publish run referenced by the verify run's own lineage;
|
|
11
|
+
* - failure classification reuses the distribute saga family: PARTIAL after
|
|
12
|
+
* an external success, BLOCKED at zero side effects, NEEDS_INPUT for a
|
|
13
|
+
* pure awaiting-approval park — and the source verify run is NEVER
|
|
14
|
+
* touched: VERIFIED never rolls back, the failure is evidenced;
|
|
15
|
+
* - requiresApproval hooks park at AWAITING_APPROVAL without a checkpoint
|
|
16
|
+
* approval (NEEDS_INPUT at zero side effects) and run once approved;
|
|
17
|
+
* - a non-verify / non-VERIFIED / differently-bound source run fails closed
|
|
18
|
+
* BEFORE any write (no run authority is allocated for a rejected lineage).
|
|
19
|
+
*
|
|
20
|
+
* Reconcile equivalence: a rerun IS the reconcile. Every hook starts from a
|
|
21
|
+
* fresh observation of its own downstream state (preset idempotence), so a
|
|
22
|
+
* failed postVerify run retries to DISTRIBUTED once the failure is cleared.
|
|
23
|
+
*
|
|
24
|
+
* @module commands/postverify
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
28
|
+
import { promisify } from 'node:util';
|
|
29
|
+
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
30
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
31
|
+
import { tmpdir } from 'node:os';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
|
|
34
|
+
import { assertImmutablePlanAuthority, computePlanDigest, validatePlan } from '../core/plan.mjs';
|
|
35
|
+
import {
|
|
36
|
+
assertImmutableApprovalAuthority,
|
|
37
|
+
computeApprovalDigest,
|
|
38
|
+
validateApproval,
|
|
39
|
+
validateApprovalRecordSchema,
|
|
40
|
+
} from '../core/approval.mjs';
|
|
41
|
+
import {
|
|
42
|
+
appendRunState,
|
|
43
|
+
createProductionRunDir,
|
|
44
|
+
loadRun,
|
|
45
|
+
resolveDefaultRunDir,
|
|
46
|
+
validateRunPlanDigest,
|
|
47
|
+
writeRunAtomic,
|
|
48
|
+
} from '../core/run.mjs';
|
|
49
|
+
import { createEvidenceWriter } from '../core/evidence.mjs';
|
|
50
|
+
import { runHook } from '../core/hooks.mjs';
|
|
51
|
+
import { boundedOutputTail } from '../core/bounded-output.mjs';
|
|
52
|
+
import {
|
|
53
|
+
buildPostPublishContext,
|
|
54
|
+
validatePostPublishDeclaration,
|
|
55
|
+
effectiveHookRequiresApproval,
|
|
56
|
+
POSTPUBLISH_CONTEXT_ENV,
|
|
57
|
+
PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
58
|
+
} from '../core/postpublish.mjs';
|
|
59
|
+
import { validatePostPublishApproval } from '../core/postpublish-approval.mjs';
|
|
60
|
+
import { executePresetHook } from '../core/preset-executor.mjs';
|
|
61
|
+
import {
|
|
62
|
+
ReleaseError,
|
|
63
|
+
GATE_FAILED,
|
|
64
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
65
|
+
} from '../core/errors.mjs';
|
|
66
|
+
|
|
67
|
+
const execFileAsync = promisify(execFileCb);
|
|
68
|
+
|
|
69
|
+
/** Executor identity recorded in every postVerify checkpoint trace. */
|
|
70
|
+
const EXECUTOR = 'release-skill postverify';
|
|
71
|
+
|
|
72
|
+
/** Full 40-hex commit sha. */
|
|
73
|
+
const SHA_RE = /^[a-f0-9]{40}$/;
|
|
74
|
+
|
|
75
|
+
/** Checkpoint error codes accepted by the release-run schema enum. */
|
|
76
|
+
const SCHEMA_ERROR_CODES = new Set([
|
|
77
|
+
'CONFIG_INVALID',
|
|
78
|
+
'BASELINE_CHANGED',
|
|
79
|
+
'DIRTY_SCOPE_CONFLICT',
|
|
80
|
+
'GATE_FAILED',
|
|
81
|
+
'AUTH_MISSING',
|
|
82
|
+
'REMOTE_CONFLICT',
|
|
83
|
+
'REMOTE_UNAVAILABLE',
|
|
84
|
+
'HOOK_TIMEOUT',
|
|
85
|
+
'PARTIAL_RELEASE',
|
|
86
|
+
'POST_PUBLISH_VERIFY_FAILED',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/** Run statuses. */
|
|
90
|
+
const DISTRIBUTING = 'DISTRIBUTING';
|
|
91
|
+
const DISTRIBUTED = 'DISTRIBUTED';
|
|
92
|
+
const PARTIAL = 'PARTIAL';
|
|
93
|
+
const BLOCKED = 'BLOCKED';
|
|
94
|
+
const NEEDS_INPUT = 'NEEDS_INPUT';
|
|
95
|
+
|
|
96
|
+
function defaultClock() {
|
|
97
|
+
return new Date().toISOString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function defaultExec(command, args, options = {}) {
|
|
101
|
+
return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: 120_000, ...options });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Map a preset/transport error code onto the run-schema checkpoint enum. */
|
|
105
|
+
function mapToSchemaCode(code) {
|
|
106
|
+
return SCHEMA_ERROR_CODES.has(code) ? code : GATE_FAILED;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Execute the postVerify phase of a frozen, approved plan as an independent
|
|
111
|
+
* run bound to the VERIFIED verify run.
|
|
112
|
+
*
|
|
113
|
+
* @param {Object} options
|
|
114
|
+
* @param {string} options.planPath - Absolute path to the frozen release plan.
|
|
115
|
+
* @param {string} options.approvalPath - Absolute path to the approval record.
|
|
116
|
+
* @param {string} options.sourceRunPath - Absolute path to the sealed VERIFIED verify run.
|
|
117
|
+
* @param {string} [options.root] - Project root (git repository). Defaults to cwd.
|
|
118
|
+
* @param {string} [options.runDir] - Evidence directory override.
|
|
119
|
+
* @param {boolean} [options.dryRun] - Rehearsal: zero hook/preset execution.
|
|
120
|
+
* @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
|
|
121
|
+
* @param {Function} [options.execFn] - Injectable git exec (tests).
|
|
122
|
+
* @param {Function} [options.runHookFn] - Injectable hook runner (tests).
|
|
123
|
+
* @param {string[]} [options.postpublishApprovalPaths] - Checkpoint approval
|
|
124
|
+
* records for requiresApproval postVerify hooks; each record binds
|
|
125
|
+
* (planDigest, hookId) and is validated fail-closed before any write.
|
|
126
|
+
*
|
|
127
|
+
* @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
|
|
128
|
+
*
|
|
129
|
+
* @throws {ReleaseError} on any safety-gate failure (before any write while
|
|
130
|
+
* the lineage is untrusted; evidenced afterwards).
|
|
131
|
+
*/
|
|
132
|
+
export async function postVerifyRelease(options) {
|
|
133
|
+
const {
|
|
134
|
+
planPath,
|
|
135
|
+
approvalPath,
|
|
136
|
+
sourceRunPath,
|
|
137
|
+
root = process.cwd(),
|
|
138
|
+
runDir: runDirOpt,
|
|
139
|
+
dryRun = false,
|
|
140
|
+
clock: clockOpt,
|
|
141
|
+
execFn,
|
|
142
|
+
runHookFn,
|
|
143
|
+
postpublishApprovalPaths,
|
|
144
|
+
} = options ?? {};
|
|
145
|
+
|
|
146
|
+
const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
|
|
147
|
+
const exec = typeof execFn === 'function' ? execFn : defaultExec;
|
|
148
|
+
const hookRunner = typeof runHookFn === 'function' ? runHookFn : runHook;
|
|
149
|
+
|
|
150
|
+
// =========================================================================
|
|
151
|
+
// Gate 1: load and validate the frozen plan (before any write — a rejected
|
|
152
|
+
// plan must never allocate a run authority).
|
|
153
|
+
// =========================================================================
|
|
154
|
+
let planRaw;
|
|
155
|
+
try {
|
|
156
|
+
planRaw = await readFile(planPath, 'utf8');
|
|
157
|
+
} catch (err) {
|
|
158
|
+
throw new ReleaseError(GATE_FAILED, `cannot read release plan: ${err.message}`, { planPath, cause: err.code });
|
|
159
|
+
}
|
|
160
|
+
let plan;
|
|
161
|
+
try {
|
|
162
|
+
plan = JSON.parse(planRaw);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
throw new ReleaseError(GATE_FAILED, `release plan is not valid JSON: ${err.message}`, { planPath });
|
|
165
|
+
}
|
|
166
|
+
validatePlan(plan);
|
|
167
|
+
assertImmutablePlanAuthority(planPath, plan);
|
|
168
|
+
|
|
169
|
+
const actualDigest = computePlanDigest(plan);
|
|
170
|
+
if (plan.digest && plan.digest !== actualDigest) {
|
|
171
|
+
throw new ReleaseError(
|
|
172
|
+
GATE_FAILED,
|
|
173
|
+
`plan digest mismatch: expected ${String(plan.digest).slice(0, 16)}..., computed ${actualDigest.slice(0, 16)}...`,
|
|
174
|
+
{ expected: plan.digest, actual: actualDigest },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (!plan.postPublish) {
|
|
178
|
+
throw new ReleaseError(
|
|
179
|
+
GATE_FAILED,
|
|
180
|
+
'release plan has no postPublish declaration; nothing to postVerify',
|
|
181
|
+
{ planPath },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// =========================================================================
|
|
186
|
+
// Gate 2: approval record validation (binding checks; the post-publish
|
|
187
|
+
// phase waives only the expiry window, exactly like distribute).
|
|
188
|
+
// =========================================================================
|
|
189
|
+
let approvalRaw;
|
|
190
|
+
try {
|
|
191
|
+
approvalRaw = await readFile(approvalPath, 'utf8');
|
|
192
|
+
} catch (err) {
|
|
193
|
+
throw new ReleaseError(GATE_FAILED, `cannot read approval record: ${err.message}`, { approvalPath, cause: err.code });
|
|
194
|
+
}
|
|
195
|
+
let approval;
|
|
196
|
+
try {
|
|
197
|
+
approval = JSON.parse(approvalRaw);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
throw new ReleaseError(GATE_FAILED, `approval record is not valid JSON: ${err.message}`, { approvalPath });
|
|
200
|
+
}
|
|
201
|
+
const approvalDigestValue = assertImmutableApprovalAuthority(approvalPath, plan, approvalRaw)
|
|
202
|
+
?? computeApprovalDigest(approvalRaw);
|
|
203
|
+
validateApprovalRecordSchema(approval);
|
|
204
|
+
validateApproval(plan, approval, { clock: clockFn, requireUnexpired: false });
|
|
205
|
+
|
|
206
|
+
// =========================================================================
|
|
207
|
+
// Gate 3: source run lineage — ONLY a VERIFIED verify run bound to this
|
|
208
|
+
// exact plan may drive postVerify. Fails closed before any write.
|
|
209
|
+
// =========================================================================
|
|
210
|
+
const verifyRun = await loadRun(sourceRunPath, { requireDigest: true });
|
|
211
|
+
if (verifyRun.command !== 'verify') {
|
|
212
|
+
throw new ReleaseError(
|
|
213
|
+
GATE_FAILED,
|
|
214
|
+
`postVerify source run must be a verify run, got command "${verifyRun.command}"`,
|
|
215
|
+
{ sourceRunPath, command: verifyRun.command },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (verifyRun.status !== 'VERIFIED') {
|
|
219
|
+
throw new ReleaseError(
|
|
220
|
+
GATE_FAILED,
|
|
221
|
+
`postVerify source run must be VERIFIED, got "${verifyRun.status}"; the verify conclusion never rolls back, so only a VERIFIED run may drive postVerify`,
|
|
222
|
+
{ sourceRunPath, status: verifyRun.status },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
validateRunPlanDigest(verifyRun, plan, { planPath });
|
|
226
|
+
|
|
227
|
+
// The publish run referenced by the verify run's own lineage supplies
|
|
228
|
+
// publishedAt (context §2.3): verify runs never fabricate it.
|
|
229
|
+
if (!verifyRun.sourceRunPath || !verifyRun.sourceRunId || !verifyRun.sourceRunDigest) {
|
|
230
|
+
throw new ReleaseError(
|
|
231
|
+
GATE_FAILED,
|
|
232
|
+
'postVerify source verify run is missing complete source run lineage; publishedAt cannot be trusted',
|
|
233
|
+
{ sourceRunPath },
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
const publishRun = await loadRun(verifyRun.sourceRunPath, { requireDigest: true });
|
|
237
|
+
if (publishRun.runId !== verifyRun.sourceRunId || publishRun.runDigest !== verifyRun.sourceRunDigest) {
|
|
238
|
+
throw new ReleaseError(
|
|
239
|
+
GATE_FAILED,
|
|
240
|
+
'postVerify source verify run lineage id/digest does not match the referenced publish run bytes',
|
|
241
|
+
{ sourceRunPath, publishRunPath: verifyRun.sourceRunPath },
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// =========================================================================
|
|
246
|
+
// Declaration re-validation + postVerify-phase hook selection.
|
|
247
|
+
// =========================================================================
|
|
248
|
+
const postPublish = plan.postPublish;
|
|
249
|
+
validatePostPublishDeclaration(postPublish, { unitId: postPublish.unitId });
|
|
250
|
+
const declaredHooks = postPublish.hooks ?? [];
|
|
251
|
+
const postVerifyHooks = declaredHooks.filter((hook) => (hook.phase ?? 'distribute') === 'postVerify');
|
|
252
|
+
|
|
253
|
+
// =========================================================================
|
|
254
|
+
// Gate 4: checkpoint approvals for requiresApproval hooks. Every provided
|
|
255
|
+
// record is validated fail-closed BEFORE any write (planDigest binding,
|
|
256
|
+
// declared hook, requiresApproval, 24h window, expiry). A bad approval
|
|
257
|
+
// aborts the whole run; a missing one parks the hook at AWAITING_APPROVAL.
|
|
258
|
+
// =========================================================================
|
|
259
|
+
const approvedHookIds = new Set();
|
|
260
|
+
const hookApprovalPaths = postpublishApprovalPaths ?? [];
|
|
261
|
+
for (const hookApprovalPath of hookApprovalPaths) {
|
|
262
|
+
let hookApprovalRaw;
|
|
263
|
+
try {
|
|
264
|
+
hookApprovalRaw = await readFile(hookApprovalPath, 'utf8');
|
|
265
|
+
} catch (err) {
|
|
266
|
+
throw new ReleaseError(
|
|
267
|
+
GATE_FAILED,
|
|
268
|
+
`cannot read postpublish hook approval: ${err.message}`,
|
|
269
|
+
{ hookApprovalPath, cause: err.code },
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
let hookApproval;
|
|
273
|
+
try {
|
|
274
|
+
hookApproval = JSON.parse(hookApprovalRaw);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
throw new ReleaseError(
|
|
277
|
+
GATE_FAILED,
|
|
278
|
+
`postpublish hook approval is not valid JSON: ${err.message}`,
|
|
279
|
+
{ hookApprovalPath },
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
validatePostPublishApproval(plan, hookApproval, { clock: clockFn });
|
|
283
|
+
if (approvedHookIds.has(hookApproval.hookId)) {
|
|
284
|
+
throw new ReleaseError(
|
|
285
|
+
GATE_FAILED,
|
|
286
|
+
`duplicate postpublish hook approvals for hook "${hookApproval.hookId}"`,
|
|
287
|
+
{ hookId: hookApproval.hookId },
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
approvedHookIds.add(hookApproval.hookId);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// =========================================================================
|
|
294
|
+
// Gate 5: tag identity — the live tag must still point at the frozen
|
|
295
|
+
// tagCommit (read-only observation; still before any write).
|
|
296
|
+
// =========================================================================
|
|
297
|
+
if (postPublish.payloadSource !== PAYLOAD_SOURCE_TAG_WORKTREE) {
|
|
298
|
+
throw new ReleaseError(
|
|
299
|
+
GATE_FAILED,
|
|
300
|
+
`postPublish.payloadSource must be "${PAYLOAD_SOURCE_TAG_WORKTREE}"`,
|
|
301
|
+
{ payloadSource: postPublish.payloadSource },
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
if (typeof postPublish.tagCommit !== 'string' || !SHA_RE.test(postPublish.tagCommit)) {
|
|
305
|
+
throw new ReleaseError(
|
|
306
|
+
GATE_FAILED,
|
|
307
|
+
'postPublish is missing a frozen tagCommit binding; postVerify fails closed',
|
|
308
|
+
{ tagCommit: postPublish.tagCommit ?? null },
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
let observedTagCommit;
|
|
312
|
+
try {
|
|
313
|
+
const { stdout } = await exec('git', ['-C', root, 'rev-parse', '--verify', `${postPublish.tag}^{commit}`]);
|
|
314
|
+
observedTagCommit = `${stdout}`.trim();
|
|
315
|
+
} catch (err) {
|
|
316
|
+
throw new ReleaseError(
|
|
317
|
+
GATE_FAILED,
|
|
318
|
+
`tag "${postPublish.tag}" does not resolve to a commit in the source repository; postVerify fails closed`,
|
|
319
|
+
{ tag: postPublish.tag, stderrTail: boundedOutputTail(err?.stderr ?? err?.message) },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
if (observedTagCommit !== postPublish.tagCommit) {
|
|
323
|
+
throw new ReleaseError(
|
|
324
|
+
GATE_FAILED,
|
|
325
|
+
`tag "${postPublish.tag}" points at ${observedTagCommit}, but the frozen plan binds tagCommit ${postPublish.tagCommit}`,
|
|
326
|
+
{ tag: postPublish.tag, frozenTagCommit: postPublish.tagCommit, observedTagCommit },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// =========================================================================
|
|
331
|
+
// All gates passed: allocate the independent run authority. From here on,
|
|
332
|
+
// every failure is evidenced; the source verify run is never touched.
|
|
333
|
+
// =========================================================================
|
|
334
|
+
const runId = `postverify-${Date.now()}`;
|
|
335
|
+
let runDir = runDirOpt ?? resolveDefaultRunDir(planPath, 'postverify', runId);
|
|
336
|
+
if (plan.production?.mode === 'github-npm-v1') {
|
|
337
|
+
runDir = await createProductionRunDir(runDir, planPath);
|
|
338
|
+
} else {
|
|
339
|
+
await mkdir(runDir, { recursive: true });
|
|
340
|
+
}
|
|
341
|
+
const runPath = join(runDir, 'release-run.json');
|
|
342
|
+
const evidence = createEvidenceWriter({ runDir, command: 'postverify', clock: clockFn });
|
|
343
|
+
|
|
344
|
+
let finalRecordWritten = false;
|
|
345
|
+
const startedAt = clockFn();
|
|
346
|
+
let stateSequence = -1;
|
|
347
|
+
|
|
348
|
+
const checkpoints = postVerifyHooks.map((hook) => ({
|
|
349
|
+
actionId: hook.id,
|
|
350
|
+
actionType: 'postpublish-hook',
|
|
351
|
+
status: 'PENDING',
|
|
352
|
+
executor: EXECUTOR,
|
|
353
|
+
}));
|
|
354
|
+
const checkpointById = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
|
|
355
|
+
|
|
356
|
+
const buildPersistedState = (status, finishedAt) => ({
|
|
357
|
+
runId,
|
|
358
|
+
command: 'postverify',
|
|
359
|
+
status,
|
|
360
|
+
planDigest: plan.digest ?? actualDigest,
|
|
361
|
+
planPath,
|
|
362
|
+
...(approvalDigestValue ? { approvalDigest: approvalDigestValue } : {}),
|
|
363
|
+
...(approvalPath ? { approvalPath } : {}),
|
|
364
|
+
sourceRunId: verifyRun.runId,
|
|
365
|
+
sourceRunDigest: verifyRun.runDigest,
|
|
366
|
+
sourceRunPath,
|
|
367
|
+
startedAt,
|
|
368
|
+
...(finishedAt ? { finishedAt } : {}),
|
|
369
|
+
checkpoints: checkpoints.map((cp) => ({
|
|
370
|
+
actionId: cp.actionId,
|
|
371
|
+
actionType: cp.actionType,
|
|
372
|
+
status: cp.status === 'SUCCEEDED' ? 'succeeded'
|
|
373
|
+
: cp.status === 'FAILED' ? 'failed'
|
|
374
|
+
: cp.status === 'SKIPPED' ? 'skipped'
|
|
375
|
+
: cp.status === 'UNCERTAIN' ? 'uncertain'
|
|
376
|
+
: cp.status === 'PENDING' ? 'pending'
|
|
377
|
+
: cp.status, // NO_CHANGE / AWAITING_APPROVAL pass through as-is
|
|
378
|
+
...(cp.mode ? { mode: cp.mode } : {}),
|
|
379
|
+
...(cp.pushedCommit ? { pushedCommit: cp.pushedCommit } : {}),
|
|
380
|
+
...(cp.executor ? { executor: cp.executor } : {}),
|
|
381
|
+
...(cp.startedAt ? { startedAt: cp.startedAt } : {}),
|
|
382
|
+
...(cp.finishedAt ? { finishedAt: cp.finishedAt } : {}),
|
|
383
|
+
...(cp.reason ? { reason: cp.reason } : {}),
|
|
384
|
+
...(cp.error ? { error: { code: cp.error.code, ...(cp.error.message ? { message: cp.error.message } : {}) } } : {}),
|
|
385
|
+
})),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const snapshot = async (status) => {
|
|
389
|
+
stateSequence += 1;
|
|
390
|
+
return appendRunState(runDir, stateSequence, buildPersistedState(status));
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/** Persist the BLOCKED run record (idempotent) once the run exists. */
|
|
394
|
+
const recordBlocked = async () => {
|
|
395
|
+
if (finalRecordWritten) return;
|
|
396
|
+
try {
|
|
397
|
+
await writeRunAtomic(runPath, buildPersistedState(BLOCKED, clockFn()));
|
|
398
|
+
finalRecordWritten = true;
|
|
399
|
+
} catch {
|
|
400
|
+
// Persistence must never mask the primary failure.
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
// Worktree cleanup: registered exactly once, idempotent, failure-tolerant.
|
|
405
|
+
let worktreePath = null;
|
|
406
|
+
let tmpBase = null;
|
|
407
|
+
const cleanupWorktree = async () => {
|
|
408
|
+
if (worktreePath) {
|
|
409
|
+
await exec('git', ['-C', root, 'worktree', 'remove', '--force', worktreePath]).catch(() => {});
|
|
410
|
+
worktreePath = null;
|
|
411
|
+
}
|
|
412
|
+
if (tmpBase) {
|
|
413
|
+
await rm(tmpBase, { recursive: true, force: true }).catch(() => {});
|
|
414
|
+
tmpBase = null;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
await evidence.append({
|
|
420
|
+
phase: 'postverify',
|
|
421
|
+
status: 'started',
|
|
422
|
+
sourceVerifyRunId: verifyRun.runId,
|
|
423
|
+
sourcePublishRunId: publishRun.runId,
|
|
424
|
+
hookCount: postVerifyHooks.length,
|
|
425
|
+
dryRun: dryRun === true,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// Durable pre-execute authority (seq 0).
|
|
429
|
+
await snapshot(DISTRIBUTING);
|
|
430
|
+
|
|
431
|
+
// R1 timing contract carries over: hooks run inside a detached worktree
|
|
432
|
+
// at the frozen tagCommit, never in the live workspace. Dry-run executes
|
|
433
|
+
// nothing, so no worktree is allocated for a rehearsal.
|
|
434
|
+
if (dryRun !== true) {
|
|
435
|
+
try {
|
|
436
|
+
tmpBase = await mkdtemp(join(tmpdir(), 'release-skill-postverify-'));
|
|
437
|
+
worktreePath = join(tmpBase, 'worktree');
|
|
438
|
+
await exec('git', ['-C', root, 'worktree', 'add', '--detach', worktreePath, postPublish.tagCommit]);
|
|
439
|
+
} catch (err) {
|
|
440
|
+
await evidence.append({
|
|
441
|
+
phase: 'worktree',
|
|
442
|
+
status: 'failed',
|
|
443
|
+
error: boundedOutputTail(err?.stderr ?? err?.message),
|
|
444
|
+
});
|
|
445
|
+
await recordBlocked();
|
|
446
|
+
throw new ReleaseError(
|
|
447
|
+
GATE_FAILED,
|
|
448
|
+
`cannot create the detached tag worktree at ${postPublish.tagCommit}: ${err?.message ?? err}`,
|
|
449
|
+
{ tagCommit: postPublish.tagCommit },
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
await evidence.append({ phase: 'worktree', status: 'passed' });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// §2.3 context projection: verifyEvidence PRESENT (postVerify phase);
|
|
456
|
+
// publishedAt from the sealed publish run; payloadDir never travels.
|
|
457
|
+
const verifyEvidence = {
|
|
458
|
+
runId: verifyRun.runId,
|
|
459
|
+
status: verifyRun.status,
|
|
460
|
+
finishedAt: verifyRun.finishedAt,
|
|
461
|
+
};
|
|
462
|
+
const hookContextProjection = buildPostPublishContext({
|
|
463
|
+
plan,
|
|
464
|
+
runId,
|
|
465
|
+
sourceRun: publishRun,
|
|
466
|
+
payloadDir: undefined,
|
|
467
|
+
phase: 'postVerify',
|
|
468
|
+
verifyEvidence,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Proposal documents must stay byte-deterministic across redeliveries of
|
|
472
|
+
// the SAME release event (NO_CHANGE idempotence): they travel with the
|
|
473
|
+
// stable lineage-derived event identity, not the per-attempt runId.
|
|
474
|
+
const proposalContextProjection = {
|
|
475
|
+
...hookContextProjection,
|
|
476
|
+
runId: `postverify-${verifyRun.runId}`,
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// =========================================================================
|
|
480
|
+
// postVerify hooks, declared order. A failure stops the chain; a
|
|
481
|
+
// requiresApproval hook without a checkpoint approval parks at
|
|
482
|
+
// AWAITING_APPROVAL and never executes; dry-run executes nothing.
|
|
483
|
+
// =========================================================================
|
|
484
|
+
let hooksStopped = false;
|
|
485
|
+
let failures = 0;
|
|
486
|
+
let awaitingApproval = 0;
|
|
487
|
+
let externalSuccesses = 0;
|
|
488
|
+
|
|
489
|
+
for (const hook of postVerifyHooks) {
|
|
490
|
+
const cp = checkpointById.get(hook.id);
|
|
491
|
+
cp.startedAt = clockFn();
|
|
492
|
+
|
|
493
|
+
if (hooksStopped) {
|
|
494
|
+
cp.status = 'SKIPPED';
|
|
495
|
+
cp.reason = 'EARLIER_HOOK_FAILED';
|
|
496
|
+
cp.finishedAt = clockFn();
|
|
497
|
+
await evidence.append({
|
|
498
|
+
phase: 'postpublish-hook',
|
|
499
|
+
hookId: hook.id,
|
|
500
|
+
status: 'skipped',
|
|
501
|
+
reason: 'EARLIER_HOOK_FAILED',
|
|
502
|
+
});
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (dryRun === true) {
|
|
507
|
+
cp.status = 'SKIPPED';
|
|
508
|
+
cp.reason = 'DRY_RUN';
|
|
509
|
+
cp.finishedAt = clockFn();
|
|
510
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'skipped', reason: 'DRY_RUN' });
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id)) {
|
|
515
|
+
// No checkpoint approval: the hook must not execute. It parks (does
|
|
516
|
+
// not stop the chain — the approved rerun re-executes it).
|
|
517
|
+
cp.status = 'AWAITING_APPROVAL';
|
|
518
|
+
cp.finishedAt = clockFn();
|
|
519
|
+
awaitingApproval += 1;
|
|
520
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'awaiting-approval' });
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// -----------------------------------------------------------------
|
|
525
|
+
// Preset hooks dispatch through the R4 preset executor (one seam for
|
|
526
|
+
// every registered preset; fail-closed wording for presets registered
|
|
527
|
+
// but not yet shipped).
|
|
528
|
+
// -----------------------------------------------------------------
|
|
529
|
+
if (hook.preset !== undefined) {
|
|
530
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
|
|
531
|
+
let delivery;
|
|
532
|
+
try {
|
|
533
|
+
delivery = await executePresetHook({
|
|
534
|
+
hook,
|
|
535
|
+
contextProjection: hookContextProjection,
|
|
536
|
+
proposalContextProjection,
|
|
537
|
+
commitIdentity: postPublish.commitIdentity,
|
|
538
|
+
root: worktreePath,
|
|
539
|
+
evidencePath: join(runDir, 'evidence.jsonl'),
|
|
540
|
+
exec,
|
|
541
|
+
hookRunner,
|
|
542
|
+
});
|
|
543
|
+
} catch (err) {
|
|
544
|
+
const code = mapToSchemaCode(err?.code);
|
|
545
|
+
cp.status = 'FAILED';
|
|
546
|
+
cp.error = { code, message: err?.message ?? String(err) };
|
|
547
|
+
cp.finishedAt = clockFn();
|
|
548
|
+
failures += 1;
|
|
549
|
+
hooksStopped = true;
|
|
550
|
+
await evidence.append({
|
|
551
|
+
phase: 'postpublish-hook',
|
|
552
|
+
hookId: hook.id,
|
|
553
|
+
status: 'failed',
|
|
554
|
+
error: err?.message ?? String(err),
|
|
555
|
+
details: { code },
|
|
556
|
+
});
|
|
557
|
+
await snapshot(PARTIAL);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (delivery.status === 'NO_CHANGE') {
|
|
562
|
+
cp.status = 'NO_CHANGE';
|
|
563
|
+
cp.mode = 'no-change';
|
|
564
|
+
cp.finishedAt = clockFn();
|
|
565
|
+
externalSuccesses += 1;
|
|
566
|
+
await evidence.append({
|
|
567
|
+
phase: 'postpublish-hook',
|
|
568
|
+
hookId: hook.id,
|
|
569
|
+
status: 'no-change',
|
|
570
|
+
...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
|
|
571
|
+
// §2.6 execution realpath evidence (R4 review m-2).
|
|
572
|
+
...(delivery.observation?.workspaceRealpath
|
|
573
|
+
? { workspaceRealpath: delivery.observation.workspaceRealpath }
|
|
574
|
+
: {}),
|
|
575
|
+
...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
|
|
576
|
+
});
|
|
577
|
+
await snapshot(PARTIAL);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
cp.status = 'SUCCEEDED';
|
|
582
|
+
if (delivery.observation?.mode === 'pushed' && delivery.observation?.pushedCommit) {
|
|
583
|
+
cp.mode = 'pushed';
|
|
584
|
+
cp.pushedCommit = delivery.observation.pushedCommit;
|
|
585
|
+
}
|
|
586
|
+
cp.finishedAt = clockFn();
|
|
587
|
+
externalSuccesses += 1;
|
|
588
|
+
await evidence.append({
|
|
589
|
+
phase: 'postpublish-hook',
|
|
590
|
+
hookId: hook.id,
|
|
591
|
+
status: 'succeeded',
|
|
592
|
+
preset: hook.preset,
|
|
593
|
+
mode: delivery.mode ?? delivery.observation?.mode,
|
|
594
|
+
...(delivery.observation?.pushedCommit ? { pushedCommit: delivery.observation.pushedCommit } : {}),
|
|
595
|
+
...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
|
|
596
|
+
...(delivery.checklist ? { checklist: delivery.checklist } : {}),
|
|
597
|
+
...(delivery.degradedToNotifyHandoff === true ? { degradedToNotifyHandoff: true } : {}),
|
|
598
|
+
...(Array.isArray(delivery.observations) ? { targets: delivery.observations } : {}),
|
|
599
|
+
// §2.6 execution realpath evidence (R4 review m-2) + explicit
|
|
600
|
+
// cross-check skip note (R4 review m-4).
|
|
601
|
+
...(delivery.observation?.workspaceRealpath
|
|
602
|
+
? { workspaceRealpath: delivery.observation.workspaceRealpath }
|
|
603
|
+
: {}),
|
|
604
|
+
...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
|
|
605
|
+
...(delivery.observation?.crossCheck ? { crossCheck: delivery.observation.crossCheck } : {}),
|
|
606
|
+
});
|
|
607
|
+
await snapshot(PARTIAL);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// -----------------------------------------------------------------
|
|
612
|
+
// Custom command hooks: executed inside the frozen tag worktree with
|
|
613
|
+
// the §2.3 context injected via the contract env var.
|
|
614
|
+
// -----------------------------------------------------------------
|
|
615
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
|
|
616
|
+
let hookExecution;
|
|
617
|
+
try {
|
|
618
|
+
hookExecution = await hookRunner(
|
|
619
|
+
{
|
|
620
|
+
command: hook.command,
|
|
621
|
+
...(hook.cwd ? { cwd: hook.cwd } : {}),
|
|
622
|
+
...(hook.timeoutMs !== undefined ? { timeoutMs: hook.timeoutMs } : {}),
|
|
623
|
+
...(hook.envAllowlist ? { envAllowlist: hook.envAllowlist } : {}),
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
root: worktreePath,
|
|
627
|
+
env: process.env,
|
|
628
|
+
injectEnv: { [POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(hookContextProjection) },
|
|
629
|
+
},
|
|
630
|
+
);
|
|
631
|
+
} catch (err) {
|
|
632
|
+
// HOOK_TIMEOUT (or a runner defect): FAILED checkpoint, stop the chain.
|
|
633
|
+
const code = err?.code === 'HOOK_TIMEOUT' ? 'HOOK_TIMEOUT' : POST_PUBLISH_VERIFY_FAILED;
|
|
634
|
+
cp.status = 'FAILED';
|
|
635
|
+
cp.error = { code, message: err?.message ?? String(err) };
|
|
636
|
+
cp.finishedAt = clockFn();
|
|
637
|
+
failures += 1;
|
|
638
|
+
hooksStopped = true;
|
|
639
|
+
await evidence.append({
|
|
640
|
+
phase: 'postpublish-hook',
|
|
641
|
+
hookId: hook.id,
|
|
642
|
+
status: 'failed',
|
|
643
|
+
error: err?.message ?? String(err),
|
|
644
|
+
});
|
|
645
|
+
await snapshot(PARTIAL);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (hookExecution.exitCode !== 0) {
|
|
650
|
+
cp.status = 'FAILED';
|
|
651
|
+
cp.error = {
|
|
652
|
+
code: POST_PUBLISH_VERIFY_FAILED,
|
|
653
|
+
message: `postVerify hook "${hook.id}" exited with code ${hookExecution.exitCode}`,
|
|
654
|
+
};
|
|
655
|
+
cp.finishedAt = clockFn();
|
|
656
|
+
failures += 1;
|
|
657
|
+
hooksStopped = true;
|
|
658
|
+
await evidence.append({
|
|
659
|
+
phase: 'postpublish-hook',
|
|
660
|
+
hookId: hook.id,
|
|
661
|
+
status: 'failed',
|
|
662
|
+
exitCode: hookExecution.exitCode,
|
|
663
|
+
stdoutTail: boundedOutputTail(hookExecution.stdout),
|
|
664
|
+
stderrTail: boundedOutputTail(hookExecution.stderr),
|
|
665
|
+
});
|
|
666
|
+
await snapshot(PARTIAL);
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
cp.status = 'SUCCEEDED';
|
|
671
|
+
cp.finishedAt = clockFn();
|
|
672
|
+
externalSuccesses += 1;
|
|
673
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'succeeded' });
|
|
674
|
+
await snapshot(PARTIAL);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// =========================================================================
|
|
678
|
+
// Classification (returned, not thrown) — distribute saga family:
|
|
679
|
+
// - DISTRIBUTED: no failures and no awaiting-approval hooks;
|
|
680
|
+
// - NEEDS_INPUT: only awaiting-approval checkpoints and zero external
|
|
681
|
+
// side effects so far (pure input-needed state, never PARTIAL);
|
|
682
|
+
// - PARTIAL: at least one external success alongside failures or
|
|
683
|
+
// awaiting-approval checkpoints;
|
|
684
|
+
// - BLOCKED: failures with zero external side effects landed.
|
|
685
|
+
// =========================================================================
|
|
686
|
+
let overallStatus;
|
|
687
|
+
if (failures === 0 && awaitingApproval === 0) {
|
|
688
|
+
overallStatus = DISTRIBUTED;
|
|
689
|
+
} else if (failures === 0) {
|
|
690
|
+
overallStatus = externalSuccesses > 0 ? PARTIAL : NEEDS_INPUT;
|
|
691
|
+
} else {
|
|
692
|
+
overallStatus = externalSuccesses > 0 ? PARTIAL : BLOCKED;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const finishedAt = clockFn();
|
|
696
|
+
await snapshot(overallStatus);
|
|
697
|
+
await writeRunAtomic(runPath, buildPersistedState(overallStatus, finishedAt));
|
|
698
|
+
finalRecordWritten = true;
|
|
699
|
+
|
|
700
|
+
await evidence.append({
|
|
701
|
+
phase: 'postverify',
|
|
702
|
+
status: 'completed',
|
|
703
|
+
overallStatus,
|
|
704
|
+
checkpointStatuses: checkpoints.map((cp) => cp.status),
|
|
705
|
+
});
|
|
706
|
+
await evidence.finish({
|
|
707
|
+
status: overallStatus,
|
|
708
|
+
planPath,
|
|
709
|
+
runPath,
|
|
710
|
+
finishedAt: clockFn(),
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
return { planPath, runPath, status: overallStatus, checkpoints };
|
|
714
|
+
} catch (err) {
|
|
715
|
+
try {
|
|
716
|
+
await evidence.append({
|
|
717
|
+
phase: 'postverify',
|
|
718
|
+
status: 'failed',
|
|
719
|
+
error: { code: err.code, message: err.message },
|
|
720
|
+
});
|
|
721
|
+
if (!finalRecordWritten) await recordBlocked();
|
|
722
|
+
} catch {
|
|
723
|
+
// Persistence must never mask the primary failure.
|
|
724
|
+
}
|
|
725
|
+
await evidence.finish({
|
|
726
|
+
status: BLOCKED,
|
|
727
|
+
error: { code: err.code, message: err.message },
|
|
728
|
+
failedAt: clockFn(),
|
|
729
|
+
}).catch(() => {});
|
|
730
|
+
throw err;
|
|
731
|
+
} finally {
|
|
732
|
+
await cleanupWorktree();
|
|
733
|
+
}
|
|
734
|
+
}
|