release-skill 0.6.2 → 0.7.0
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 +48 -0
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +47 -2
- package/INSTALL.zh-CN.md +29 -2
- package/README.md +137 -12
- package/README.zh-CN.md +110 -9
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/kernel-protocol.json +1 -1
- package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/claude/bin/license-texts/MIT.txt +21 -0
- package/adapters/claude/bin/registry.json +64 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/claude/bin/rules.json +1 -1
- 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 +200 -4
- package/adapters/claude/schemas/release-project.schema.json +86 -5
- package/adapters/claude/schemas/release-run.schema.json +11 -6
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/kernel-protocol.json +1 -1
- package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/codex/bin/license-texts/MIT.txt +21 -0
- package/adapters/codex/bin/registry.json +64 -1
- package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/codex/bin/rules.json +1 -1
- 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 +200 -4
- package/adapters/codex/schemas/release-project.schema.json +86 -5
- package/adapters/codex/schemas/release-run.schema.json +11 -6
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/kernel-protocol.json +1 -1
- package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
- package/adapters/kimi/bin/registry.json +64 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/kimi/bin/rules.json +1 -1
- 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 +200 -4
- package/adapters/kimi/schemas/release-project.schema.json +86 -5
- package/adapters/kimi/schemas/release-run.schema.json +11 -6
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
- package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
- package/adapters/workbuddy/bin/registry.json +64 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/workbuddy/bin/rules.json +1 -1
- 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 +200 -4
- package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
- package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
- package/bin/error-codes.json +1 -1
- package/bin/kernel-protocol.json +1 -1
- package/bin/license-texts/Apache-2.0.txt +201 -0
- package/bin/license-texts/MIT.txt +21 -0
- package/bin/registry.json +64 -1
- package/bin/release-skill-cli.mjs +187 -9
- package/bin/release-skill.bundle.mjs +50570 -42208
- package/bin/rules.json +1 -1
- package/package.json +6 -4
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +5 -5
- package/references/01-state-machine.md +22 -2
- package/references/02-project-config.md +1 -1
- package/references/06-adapter-contract.md +6 -3
- package/schemas/.render-manifest.json +10 -6
- package/schemas/postpublish-approval-record.schema.json +47 -0
- package/schemas/release-plan.schema.json +200 -4
- package/schemas/release-project.schema.json +86 -5
- package/schemas/release-run.schema.json +11 -6
- package/src/adapters/distribute-git.mjs +17 -11
- package/src/commands/approve.mjs +163 -1
- package/src/commands/distribute.mjs +649 -89
- package/src/commands/postverify.mjs +808 -0
- package/src/commands/prepare.mjs +456 -45
- 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 +171 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/evidence.mjs +8 -1
- package/src/core/git-url-policy.mjs +214 -0
- package/src/core/hooks.mjs +28 -0
- package/src/core/marketplace-registry-entry.mjs +175 -0
- package/src/core/notify-handoff.mjs +76 -0
- package/src/core/postpublish-approval.mjs +241 -0
- package/src/core/postpublish-bundle.mjs +342 -0
- package/src/core/postpublish-projection.mjs +210 -0
- package/src/core/postpublish.mjs +449 -25
- package/src/core/preset-executor.mjs +175 -0
- package/src/core/preset-gitwrite.mjs +484 -0
- package/src/core/presets.mjs +754 -0
- package/src/core/proposal-inbox.mjs +646 -0
- package/src/core/redact.mjs +16 -3
- package/src/core/run.mjs +91 -6
- package/src/producers/build-adapters.mjs +28 -14
package/src/commands/ship.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile, lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { canonicalJson, sha256Hex } from '../core/digest.mjs';
|
|
5
|
+
import { effectiveHookRequiresApproval } from '../core/postpublish.mjs';
|
|
5
6
|
import {
|
|
6
7
|
ReleaseError,
|
|
7
8
|
GATE_FAILED,
|
|
@@ -61,6 +62,7 @@ async function defaultDependencies() {
|
|
|
61
62
|
reconcileModule,
|
|
62
63
|
verifyModule,
|
|
63
64
|
distributeModule,
|
|
65
|
+
postverifyModule,
|
|
64
66
|
transportModule,
|
|
65
67
|
metadataModule,
|
|
66
68
|
] = await Promise.all([
|
|
@@ -71,6 +73,7 @@ async function defaultDependencies() {
|
|
|
71
73
|
import('./reconcile.mjs'),
|
|
72
74
|
import('./verify.mjs'),
|
|
73
75
|
import('./distribute.mjs'),
|
|
76
|
+
import('./postverify.mjs'),
|
|
74
77
|
import('../core/git-transport.mjs'),
|
|
75
78
|
import('../core/release-metadata.mjs'),
|
|
76
79
|
]);
|
|
@@ -82,6 +85,7 @@ async function defaultDependencies() {
|
|
|
82
85
|
reconcileRelease: reconcileModule.reconcileRelease,
|
|
83
86
|
verifyRelease: verifyModule.verifyRelease,
|
|
84
87
|
distributeRelease: distributeModule.distributeRelease,
|
|
88
|
+
postVerifyRelease: postverifyModule.postVerifyRelease,
|
|
85
89
|
preflightGitTransports: transportModule.preflightGitTransports,
|
|
86
90
|
updatePreviousPublicBaselines: metadataModule.updatePreviousPublicBaselines,
|
|
87
91
|
};
|
|
@@ -106,6 +110,7 @@ function publicState(state) {
|
|
|
106
110
|
...(state.approvalPath ? { approvalPath: state.approvalPath } : {}),
|
|
107
111
|
...(state.sourceRunPath ? { sourceRunPath: state.sourceRunPath } : {}),
|
|
108
112
|
...(state.distributeRunPath ? { distributeRunPath: state.distributeRunPath } : {}),
|
|
113
|
+
...(state.postVerify ? { postVerify: state.postVerify } : {}),
|
|
109
114
|
...(state.requirements ? { requirements: state.requirements } : {}),
|
|
110
115
|
...(state.manualFollowUps ? { manualFollowUps: state.manualFollowUps } : {}),
|
|
111
116
|
...(state.metadataUpdate ? { metadataUpdate: state.metadataUpdate } : {}),
|
|
@@ -140,6 +145,41 @@ async function buildApprovalSummary(planPath) {
|
|
|
140
145
|
}
|
|
141
146
|
}
|
|
142
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Re-entry gate helper (R4 review followup 8): true when EVERY unclosed
|
|
150
|
+
* postVerify hook is ungated (effective requiresApproval false), so a failed
|
|
151
|
+
* non-gated hook (e.g. notify-handoff) can be retried without any
|
|
152
|
+
* --hook-approval file. The unclosed set comes from the last postVerify run
|
|
153
|
+
* record when readable (checkpoints not succeeded/NO_CHANGE); an unreadable
|
|
154
|
+
* or missing record falls back to ALL declared hooks — fail-safe, because a
|
|
155
|
+
* gated hook in doubt keeps the gate shut.
|
|
156
|
+
*
|
|
157
|
+
* @param {object} state - Ship state (postVerify.runPath inspected).
|
|
158
|
+
* @param {object[]} postVerifyHooks - Declared phase:postVerify hooks.
|
|
159
|
+
* @returns {Promise<boolean>}
|
|
160
|
+
*/
|
|
161
|
+
async function allUnclosedPostVerifyHooksUngated(state, postVerifyHooks) {
|
|
162
|
+
if (!Array.isArray(postVerifyHooks) || postVerifyHooks.length === 0) return false;
|
|
163
|
+
let candidates = postVerifyHooks;
|
|
164
|
+
if (state.postVerify?.runPath) {
|
|
165
|
+
try {
|
|
166
|
+
const record = JSON.parse(await readFile(state.postVerify.runPath, 'utf8'));
|
|
167
|
+
const checkpoints = Array.isArray(record.checkpoints) ? record.checkpoints : [];
|
|
168
|
+
const closedIds = new Set(
|
|
169
|
+
checkpoints
|
|
170
|
+
.filter((cp) => cp?.actionType === 'postpublish-hook'
|
|
171
|
+
&& (cp.status === 'succeeded' || cp.status === 'NO_CHANGE'))
|
|
172
|
+
.map((cp) => cp.actionId),
|
|
173
|
+
);
|
|
174
|
+
candidates = postVerifyHooks.filter((hook) => !closedIds.has(hook.id));
|
|
175
|
+
} catch {
|
|
176
|
+
candidates = postVerifyHooks; // unreadable record: fail safe
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (candidates.length === 0) return false;
|
|
180
|
+
return candidates.every((hook) => effectiveHookRequiresApproval(hook) === false);
|
|
181
|
+
}
|
|
182
|
+
|
|
143
183
|
/**
|
|
144
184
|
* Advance one durable production release. Re-running is safe: the state file
|
|
145
185
|
* carries the immutable plan, approval and source-run paths so the command
|
|
@@ -304,11 +344,18 @@ export async function advanceShip(options = {}, injected = {}) {
|
|
|
304
344
|
await writeJsonAtomic(statePath, state);
|
|
305
345
|
}
|
|
306
346
|
|
|
347
|
+
// Double-run guard (R4 review followup 5): when the PUBLISHED block's
|
|
348
|
+
// step 3 executes postVerify inside this very call, the re-entry block
|
|
349
|
+
// after the lifecycle must NOT immediately re-run it.
|
|
350
|
+
let postVerifyRanThisCall = false;
|
|
351
|
+
|
|
307
352
|
if (state.status === 'PUBLISHED' || state.status === 'NEEDS_MANUAL_ATTESTATIONS') {
|
|
308
|
-
// Step 1: Check if postPublish requires distribution
|
|
353
|
+
// Step 1: Check if postPublish requires distribution.
|
|
354
|
+
// Hooks-only declarations (no targets) still route through distribute.
|
|
309
355
|
const plan = JSON.parse(await readFile(state.planPath, 'utf8'));
|
|
310
|
-
const
|
|
311
|
-
|
|
356
|
+
const hasDistributeWork = (plan.postPublish?.targets?.length ?? 0) > 0
|
|
357
|
+
|| (plan.postPublish?.hooks?.length ?? 0) > 0;
|
|
358
|
+
const needsDistribution = Boolean(plan.postPublish) && hasDistributeWork;
|
|
312
359
|
if (needsDistribution && deps.distributeRelease) {
|
|
313
360
|
state.status = 'DISTRIBUTING';
|
|
314
361
|
await writeJsonAtomic(statePath, state);
|
|
@@ -321,17 +368,23 @@ export async function advanceShip(options = {}, injected = {}) {
|
|
|
321
368
|
root,
|
|
322
369
|
dryRun: false,
|
|
323
370
|
planPath: state.planPath,
|
|
371
|
+
// Checkpoint approvals for requiresApproval distribute-phase hooks
|
|
372
|
+
// (review major-1: same seam the distribute CLI already uses).
|
|
373
|
+
...(options.postpublishApprovalPaths ? { postpublishApprovalPaths: options.postpublishApprovalPaths } : {}),
|
|
324
374
|
});
|
|
325
375
|
|
|
326
376
|
state = {
|
|
327
377
|
...state,
|
|
328
378
|
status: distributed.status,
|
|
329
|
-
distributeRunPath: distributed.distributeRunPath,
|
|
379
|
+
distributeRunPath: distributed.distributeRunPath ?? distributed.runPath,
|
|
330
380
|
updatedAt: new Date().toISOString(),
|
|
331
381
|
};
|
|
332
382
|
await writeJsonAtomic(statePath, state);
|
|
333
383
|
|
|
334
|
-
|
|
384
|
+
// In-memory saga checkpoints carry UPPERCASE statuses ('FAILED');
|
|
385
|
+
// the persisted run record is the lowercase projection. Comparing
|
|
386
|
+
// against 'failed' here was dead code until v0.6.3 (review note-5).
|
|
387
|
+
if (!distributed.checkpoints || distributed.checkpoints.some((cp) => cp.status === 'FAILED')) {
|
|
335
388
|
state.status = 'PARTIAL';
|
|
336
389
|
await writeJsonAtomic(statePath, state);
|
|
337
390
|
return publicState(state);
|
|
@@ -372,6 +425,100 @@ export async function advanceShip(options = {}, injected = {}) {
|
|
|
372
425
|
};
|
|
373
426
|
await writeJsonAtomic(statePath, state);
|
|
374
427
|
}
|
|
428
|
+
|
|
429
|
+
// Step 3: postVerify phase (design §2.4). phase:postVerify hooks run in an
|
|
430
|
+
// independent run after the main run is VERIFIED, with the verify run as
|
|
431
|
+
// lineage source. A PARTIAL postVerify run or a postVerify gate failure
|
|
432
|
+
// NEVER demotes VERIFIED — the outcome is recorded on the ship state.
|
|
433
|
+
const postVerifyHooks = (plan.postPublish?.hooks ?? [])
|
|
434
|
+
.filter((hook) => hook.phase === 'postVerify');
|
|
435
|
+
if (state.status === 'VERIFIED' && postVerifyHooks.length > 0 && deps.postVerifyRelease) {
|
|
436
|
+
postVerifyRanThisCall = true;
|
|
437
|
+
let postVerifyOutcome;
|
|
438
|
+
try {
|
|
439
|
+
const postVerified = await deps.postVerifyRelease({
|
|
440
|
+
planPath: state.planPath,
|
|
441
|
+
approvalPath: state.approvalPath,
|
|
442
|
+
sourceRunPath: state.verifyRunPath,
|
|
443
|
+
root,
|
|
444
|
+
postpublishApprovalPaths: options.postpublishApprovalPaths,
|
|
445
|
+
});
|
|
446
|
+
postVerifyOutcome = {
|
|
447
|
+
status: postVerified.status,
|
|
448
|
+
runPath: postVerified.runPath,
|
|
449
|
+
};
|
|
450
|
+
} catch (error) {
|
|
451
|
+
postVerifyOutcome = {
|
|
452
|
+
status: 'FAILED',
|
|
453
|
+
error: {
|
|
454
|
+
code: error?.code ?? GATE_FAILED,
|
|
455
|
+
message: error?.message ?? String(error),
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
state = {
|
|
460
|
+
...state,
|
|
461
|
+
postVerify: postVerifyOutcome,
|
|
462
|
+
updatedAt: new Date().toISOString(),
|
|
463
|
+
};
|
|
464
|
+
await writeJsonAtomic(statePath, state);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// postVerify re-entry (design §2.4; review major-1). Once the main run is
|
|
469
|
+
// VERIFIED, ship no longer enters the PUBLISHED block, so a postVerify
|
|
470
|
+
// outcome parked at AWAITING_APPROVAL (run status NEEDS_INPUT) could never
|
|
471
|
+
// complete through the CLI. The gate opens when a checkpoint approval is
|
|
472
|
+
// provided OR every unclosed postVerify hook is ungated (R4 review
|
|
473
|
+
// followup 8: a failed requiresApproval:false hook retries without any
|
|
474
|
+
// approval file; a gated hook still unclosed keeps the gate shut).
|
|
475
|
+
// Re-entry re-runs the phase — rerun IS the reconcile: approved hooks
|
|
476
|
+
// execute, already-delivered hooks stay idempotent, the verify run remains
|
|
477
|
+
// the lineage source, and the main VERIFIED status never changes. When the
|
|
478
|
+
// gate stays shut this block is a no-op: zero postVerify work, no writes.
|
|
479
|
+
const reentryApprovalPaths = options.postpublishApprovalPaths ?? [];
|
|
480
|
+
if (
|
|
481
|
+
state.status === 'VERIFIED'
|
|
482
|
+
&& deps.postVerifyRelease
|
|
483
|
+
&& !postVerifyRanThisCall
|
|
484
|
+
&& (!state.postVerify || state.postVerify.status !== 'DISTRIBUTED')
|
|
485
|
+
) {
|
|
486
|
+
const reentryPlan = JSON.parse(await readFile(state.planPath, 'utf8'));
|
|
487
|
+
const reentryPostVerifyHooks = (reentryPlan.postPublish?.hooks ?? [])
|
|
488
|
+
.filter((hook) => hook.phase === 'postVerify');
|
|
489
|
+
const approvallessRetryAllowed = reentryApprovalPaths.length === 0
|
|
490
|
+
&& await allUnclosedPostVerifyHooksUngated(state, reentryPostVerifyHooks);
|
|
491
|
+
if (reentryPostVerifyHooks.length > 0
|
|
492
|
+
&& (reentryApprovalPaths.length > 0 || approvallessRetryAllowed)) {
|
|
493
|
+
let postVerifyOutcome;
|
|
494
|
+
try {
|
|
495
|
+
const postVerified = await deps.postVerifyRelease({
|
|
496
|
+
planPath: state.planPath,
|
|
497
|
+
approvalPath: state.approvalPath,
|
|
498
|
+
sourceRunPath: state.verifyRunPath,
|
|
499
|
+
root,
|
|
500
|
+
postpublishApprovalPaths: reentryApprovalPaths,
|
|
501
|
+
});
|
|
502
|
+
postVerifyOutcome = {
|
|
503
|
+
status: postVerified.status,
|
|
504
|
+
runPath: postVerified.runPath,
|
|
505
|
+
};
|
|
506
|
+
} catch (error) {
|
|
507
|
+
postVerifyOutcome = {
|
|
508
|
+
status: 'FAILED',
|
|
509
|
+
error: {
|
|
510
|
+
code: error?.code ?? GATE_FAILED,
|
|
511
|
+
message: error?.message ?? String(error),
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
state = {
|
|
516
|
+
...state,
|
|
517
|
+
postVerify: postVerifyOutcome,
|
|
518
|
+
updatedAt: new Date().toISOString(),
|
|
519
|
+
};
|
|
520
|
+
await writeJsonAtomic(statePath, state);
|
|
521
|
+
}
|
|
375
522
|
}
|
|
376
523
|
|
|
377
524
|
return publicState(state);
|
package/src/commands/verify.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import { validatePlan, computePlanDigest, validatePlanActionCompleteness } from
|
|
|
25
25
|
import { createEvidenceWriter } from '../core/evidence.mjs';
|
|
26
26
|
import {
|
|
27
27
|
loadRun,
|
|
28
|
+
resolveRunPath,
|
|
28
29
|
validateRunPlanDigest,
|
|
29
30
|
validateRunCheckpointMapping,
|
|
30
31
|
validateRunLineage,
|
|
@@ -822,6 +823,47 @@ async function discoverDistributeRuns({ planPath, plan }) {
|
|
|
822
823
|
return candidates;
|
|
823
824
|
}
|
|
824
825
|
|
|
826
|
+
/**
|
|
827
|
+
* Evaluate one distribute run against the verify distribute gate
|
|
828
|
+
* (v0.6.3 R1, design §2.4/§2.7).
|
|
829
|
+
*
|
|
830
|
+
* Semantics:
|
|
831
|
+
* - A DISTRIBUTED run passes outright (no warning).
|
|
832
|
+
* - A PARTIAL run passes only when EVERY checkpoint that is not
|
|
833
|
+
* succeeded/skipped is a FAILED `postpublish-hook` checkpoint whose hook
|
|
834
|
+
* declaration in the frozen plan explicitly downgrades with
|
|
835
|
+
* `blocksVerified: false` (default true). Each such downgrade is reported
|
|
836
|
+
* as a warning exemption — prominently evidenced, never silent.
|
|
837
|
+
* - AWAITING_APPROVAL checkpoints never pass (the checkpoint approval is
|
|
838
|
+
* still missing), and target/mirror checkpoints are never exemptable.
|
|
839
|
+
* - Any other run status (BLOCKED / NEEDS_INPUT / ...) never passes, per the
|
|
840
|
+
* governance rule that NEEDS_INPUT/BLOCKED cannot silently become VERIFIED.
|
|
841
|
+
*
|
|
842
|
+
* @param {Object} run - A distribute run record (checkpoints + status).
|
|
843
|
+
* @param {Object} plan - The frozen release plan (postPublish.hooks source).
|
|
844
|
+
* @returns {{ pass: boolean, warned: boolean, exemptions: Object[] }}
|
|
845
|
+
*/
|
|
846
|
+
export function evaluateDistributeGateRun(run, plan) {
|
|
847
|
+
if (!run || typeof run !== 'object') return { pass: false, warned: false, exemptions: [] };
|
|
848
|
+
if (run.status === 'DISTRIBUTED') return { pass: true, warned: false, exemptions: [] };
|
|
849
|
+
if (run.status !== 'PARTIAL') return { pass: false, warned: false, exemptions: [] };
|
|
850
|
+
|
|
851
|
+
const hooksById = new Map(((plan?.postPublish?.hooks ?? [])).map((hook) => [hook.id, hook]));
|
|
852
|
+
const exemptions = [];
|
|
853
|
+
for (const checkpoint of run.checkpoints ?? []) {
|
|
854
|
+
if (checkpoint.status === 'succeeded' || checkpoint.status === 'skipped') continue;
|
|
855
|
+
const hook = checkpoint.actionType === 'postpublish-hook'
|
|
856
|
+
? hooksById.get(checkpoint.actionId)
|
|
857
|
+
: undefined;
|
|
858
|
+
if (hook && checkpoint.status === 'failed' && hook.blocksVerified === false) {
|
|
859
|
+
exemptions.push({ actionId: checkpoint.actionId, status: checkpoint.status });
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
return { pass: false, warned: false, exemptions: [] };
|
|
863
|
+
}
|
|
864
|
+
return { pass: true, warned: exemptions.length > 0, exemptions };
|
|
865
|
+
}
|
|
866
|
+
|
|
825
867
|
// ---------------------------------------------------------------------------
|
|
826
868
|
// Public API
|
|
827
869
|
// ---------------------------------------------------------------------------
|
|
@@ -831,7 +873,8 @@ async function discoverDistributeRuns({ planPath, plan }) {
|
|
|
831
873
|
*
|
|
832
874
|
* @param {Object} options
|
|
833
875
|
* @param {string} options.planPath - Absolute path to the frozen release plan.
|
|
834
|
-
* @param {string} options.sourceRunPath - Absolute path to the source run
|
|
876
|
+
* @param {string} options.sourceRunPath - Absolute path to the source run
|
|
877
|
+
* file, or to its containing run directory (resolved to release-run.json; O6).
|
|
835
878
|
* @param {Object} options.adapterRegistry - Adapter registry for verification.
|
|
836
879
|
* @param {string} [options.root] - Project root for source access.
|
|
837
880
|
* @param {string} [options.runDir] - Evidence directory.
|
|
@@ -870,6 +913,11 @@ export async function verifyRelease(options) {
|
|
|
870
913
|
);
|
|
871
914
|
}
|
|
872
915
|
|
|
916
|
+
// O6: --run accepts a run directory as well as the run file. Resolve a
|
|
917
|
+
// directory to its release-run.json before any load/lineage/authority step;
|
|
918
|
+
// a file input passes through unchanged (compat).
|
|
919
|
+
const resolvedSourceRunPath = await resolveRunPath(sourceRunPath);
|
|
920
|
+
|
|
873
921
|
// Load and validate the plan before creating any evidence directory. A
|
|
874
922
|
// production plan grants authority only to a fresh direct child of its
|
|
875
923
|
// sibling .release-skill/runs directory.
|
|
@@ -936,14 +984,14 @@ export async function verifyRelease(options) {
|
|
|
936
984
|
// =======================================================================
|
|
937
985
|
await evidence.append({ phase: 'verify', step: 'source-run-load', status: 'started' });
|
|
938
986
|
|
|
939
|
-
const sourceRun = await loadRun(
|
|
987
|
+
const sourceRun = await loadRun(resolvedSourceRunPath, {
|
|
940
988
|
requireDigest: Boolean(plan.production),
|
|
941
989
|
...(plan.production ? { authorityPlanPath: planPath } : {}),
|
|
942
990
|
});
|
|
943
991
|
await validateRunLineage(sourceRun, {
|
|
944
992
|
plan,
|
|
945
993
|
planPath,
|
|
946
|
-
runPath:
|
|
994
|
+
runPath: resolvedSourceRunPath,
|
|
947
995
|
production: Boolean(plan.production),
|
|
948
996
|
});
|
|
949
997
|
|
|
@@ -966,29 +1014,58 @@ export async function verifyRelease(options) {
|
|
|
966
1014
|
}
|
|
967
1015
|
|
|
968
1016
|
// =======================================================================
|
|
969
|
-
// Step 2b: Check for postPublish distribution requirement
|
|
1017
|
+
// Step 2b: Check for postPublish distribution requirement.
|
|
1018
|
+
// v0.6.3 R1: the gate triggers on targets OR hooks declarations, and a
|
|
1019
|
+
// PARTIAL distribute run passes only through the blocksVerified:false
|
|
1020
|
+
// exemption path (evaluateDistributeGateRun) — warned, never silent.
|
|
970
1021
|
// =======================================================================
|
|
971
|
-
|
|
1022
|
+
const declaredPostPublishTargets = plan.postPublish?.targets ?? [];
|
|
1023
|
+
const declaredPostPublishHooks = plan.postPublish?.hooks ?? [];
|
|
1024
|
+
if (plan.postPublish
|
|
1025
|
+
&& (declaredPostPublishTargets.length > 0 || declaredPostPublishHooks.length > 0)) {
|
|
972
1026
|
await evidence.append({ phase: 'verify', step: 'distribute-run-discovery', status: 'started' });
|
|
973
|
-
|
|
974
|
-
const distributeCandidates = await discoverDistributeRuns({ planPath, plan });
|
|
975
|
-
|
|
976
|
-
|
|
1027
|
+
|
|
1028
|
+
const distributeCandidates = await discoverDistributeRuns({ planPath, plan }) ?? [];
|
|
1029
|
+
|
|
1030
|
+
let gateRun = distributeCandidates.find((c) => c.status === 'DISTRIBUTED') ?? null;
|
|
1031
|
+
let gateVerdict = gateRun ? { pass: true, warned: false, exemptions: [] } : null;
|
|
1032
|
+
if (!gateRun) {
|
|
1033
|
+
for (const candidate of distributeCandidates) {
|
|
1034
|
+
const verdict = evaluateDistributeGateRun(candidate, plan);
|
|
1035
|
+
if (verdict.pass) {
|
|
1036
|
+
gateRun = candidate;
|
|
1037
|
+
gateVerdict = verdict;
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
977
1043
|
await evidence.append({
|
|
978
1044
|
phase: 'verify',
|
|
979
1045
|
step: 'distribute-run-discovery',
|
|
980
1046
|
status: 'checked',
|
|
981
|
-
foundCandidate:
|
|
982
|
-
distributeRunPath:
|
|
1047
|
+
foundCandidate: Boolean(gateRun),
|
|
1048
|
+
distributeRunPath: gateRun?.runPath ?? null,
|
|
983
1049
|
});
|
|
984
|
-
|
|
985
|
-
if (!
|
|
1050
|
+
|
|
1051
|
+
if (!gateRun) {
|
|
986
1052
|
throw new ReleaseError(
|
|
987
1053
|
GATE_FAILED,
|
|
988
|
-
`distribution required by plan.postPublish but no DISTRIBUTED run found; run release-skill distribute --plan ${planPath} --root ${root}`,
|
|
1054
|
+
`distribution required by plan.postPublish but no DISTRIBUTED (or blocksVerified-exempted) distribute run found; run release-skill distribute --plan ${planPath} --root ${root}`,
|
|
989
1055
|
{ requiredDistribution: true, evidenceEvent: 'distribute-run-missing' },
|
|
990
1056
|
);
|
|
991
1057
|
}
|
|
1058
|
+
|
|
1059
|
+
if (gateVerdict.warned) {
|
|
1060
|
+
await evidence.append({
|
|
1061
|
+
phase: 'verify',
|
|
1062
|
+
step: 'distribute-gate',
|
|
1063
|
+
status: 'warning',
|
|
1064
|
+
warning: 'blocksVerified:false postPublish hook failure exempted; VERIFIED proceeds with a downgrade warning',
|
|
1065
|
+
exemptions: gateVerdict.exemptions,
|
|
1066
|
+
distributeRunPath: gateRun.runPath ?? null,
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
992
1069
|
}
|
|
993
1070
|
|
|
994
1071
|
if (plan.production) {
|
|
@@ -1772,7 +1849,7 @@ export async function verifyRelease(options) {
|
|
|
1772
1849
|
} : {}),
|
|
1773
1850
|
sourceRunId: sourceRun.runId,
|
|
1774
1851
|
sourceRunDigest,
|
|
1775
|
-
sourceRunPath,
|
|
1852
|
+
sourceRunPath: resolvedSourceRunPath,
|
|
1776
1853
|
status: VERIFIED,
|
|
1777
1854
|
checkpoints: actions.map((a) => {
|
|
1778
1855
|
const check = adapterChecks.find((c) => c.actionId === a.id);
|
package/src/core/approval.mjs
CHANGED
|
@@ -58,6 +58,94 @@ export function computeApprovalDigest(rawApproval) {
|
|
|
58
58
|
: JSON.stringify(rawApproval, null, 2));
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Maximum approval window: 24 hours. */
|
|
62
|
+
export const MAX_APPROVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
63
|
+
|
|
64
|
+
/** Clock-skew tolerance for approvedAt: 5 minutes. */
|
|
65
|
+
export const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate the time-window semantics shared by every approval record kind
|
|
69
|
+
* (plan-level approvals and v0.6.3 R1 checkpoint-level postPublish hook
|
|
70
|
+
* approvals): well-formed timestamps, expiresAt after approvedAt, a maximum
|
|
71
|
+
* 24h window, approvedAt not in the future beyond 5-minute clock skew, and
|
|
72
|
+
* (unless requireUnexpired === false) an unexpired window at `clock`.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ approvedAt?: string, expiresAt?: string }} approval
|
|
75
|
+
* @param {Object} [options]
|
|
76
|
+
* @param {() => string} [options.clock] Clock function returning ISO-8601 strings.
|
|
77
|
+
* @param {boolean} [options.requireUnexpired] Default true.
|
|
78
|
+
*
|
|
79
|
+
* @throws {ReleaseError} GATE_FAILED on any violation.
|
|
80
|
+
*/
|
|
81
|
+
export function validateApprovalTimeWindow(approval, options = {}) {
|
|
82
|
+
const clockFn = typeof options.clock === 'function' ? options.clock : () => new Date().toISOString();
|
|
83
|
+
|
|
84
|
+
const approvedAtDate = new Date(approval.approvedAt);
|
|
85
|
+
if (Number.isNaN(approvedAtDate.getTime())) {
|
|
86
|
+
throw new ReleaseError(
|
|
87
|
+
GATE_FAILED,
|
|
88
|
+
`invalid approvedAt: "${approval.approvedAt}"`,
|
|
89
|
+
{ approvedAt: approval.approvedAt },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const expiresAtDate = new Date(approval.expiresAt);
|
|
94
|
+
if (Number.isNaN(expiresAtDate.getTime())) {
|
|
95
|
+
throw new ReleaseError(
|
|
96
|
+
GATE_FAILED,
|
|
97
|
+
`invalid expiresAt: "${approval.expiresAt}"`,
|
|
98
|
+
{ expiresAt: approval.expiresAt },
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (expiresAtDate.getTime() <= approvedAtDate.getTime()) {
|
|
103
|
+
throw new ReleaseError(
|
|
104
|
+
GATE_FAILED,
|
|
105
|
+
`expiresAt (${approval.expiresAt}) must be after approvedAt (${approval.approvedAt})`,
|
|
106
|
+
{ approvedAt: approval.approvedAt, expiresAt: approval.expiresAt },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Max 24h approval window ---
|
|
111
|
+
const approvalDurationMs = expiresAtDate.getTime() - approvedAtDate.getTime();
|
|
112
|
+
if (approvalDurationMs > MAX_APPROVAL_MS) {
|
|
113
|
+
throw new ReleaseError(
|
|
114
|
+
GATE_FAILED,
|
|
115
|
+
`approval duration ${Math.round(approvalDurationMs / 3600000)}h exceeds maximum 24h`,
|
|
116
|
+
{ approvedAt: approval.approvedAt, expiresAt: approval.expiresAt, durationHours: approvalDurationMs / 3600000 },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- Reject future approvedAt (beyond 5-minute clock skew tolerance) ---
|
|
121
|
+
const now = clockFn();
|
|
122
|
+
const nowDate = new Date(now);
|
|
123
|
+
if (Number.isNaN(nowDate.getTime())) {
|
|
124
|
+
throw new ReleaseError(
|
|
125
|
+
GATE_FAILED,
|
|
126
|
+
`invalid clock value: "${now}"`,
|
|
127
|
+
{ clock: now },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (approvedAtDate.getTime() > nowDate.getTime() + CLOCK_SKEW_TOLERANCE_MS) {
|
|
131
|
+
throw new ReleaseError(
|
|
132
|
+
GATE_FAILED,
|
|
133
|
+
`approvedAt (${approval.approvedAt}) is in the future (current time: ${now})`,
|
|
134
|
+
{ approvedAt: approval.approvedAt, now },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- Expiry (publish/reconcile require current approval; verify may only
|
|
139
|
+
// revalidate the immutable approval identity after publication) ---
|
|
140
|
+
if (options.requireUnexpired !== false && nowDate > expiresAtDate) {
|
|
141
|
+
throw new ReleaseError(
|
|
142
|
+
GATE_FAILED,
|
|
143
|
+
`approval expired at ${approval.expiresAt}, current time is ${now}`,
|
|
144
|
+
{ expiresAt: approval.expiresAt, now },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
61
149
|
export function assertImmutableApprovalAuthority(approvalPath, plan, rawApproval) {
|
|
62
150
|
if (!plan?.production) return;
|
|
63
151
|
const planDigest = computePlanDigest(plan);
|
|
@@ -89,8 +177,6 @@ export function assertImmutableApprovalAuthority(approvalPath, plan, rawApproval
|
|
|
89
177
|
* @throws {ReleaseError} GATE_FAILED if any validation check fails.
|
|
90
178
|
*/
|
|
91
179
|
export function validateApproval(plan, approval, options = {}) {
|
|
92
|
-
const clockFn = typeof options.clock === 'function' ? options.clock : () => new Date().toISOString();
|
|
93
|
-
|
|
94
180
|
// --- Required fields ---
|
|
95
181
|
if (!approval || typeof approval !== 'object') {
|
|
96
182
|
throw new ReleaseError(
|
|
@@ -290,71 +376,10 @@ export function validateApproval(plan, approval, options = {}) {
|
|
|
290
376
|
}
|
|
291
377
|
}
|
|
292
378
|
|
|
293
|
-
// --- Time validation ---
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
`invalid approvedAt: "${approval.approvedAt}"`,
|
|
299
|
-
{ approvedAt: approval.approvedAt },
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const expiresAtDate = new Date(approval.expiresAt);
|
|
304
|
-
if (Number.isNaN(expiresAtDate.getTime())) {
|
|
305
|
-
throw new ReleaseError(
|
|
306
|
-
GATE_FAILED,
|
|
307
|
-
`invalid expiresAt: "${approval.expiresAt}"`,
|
|
308
|
-
{ expiresAt: approval.expiresAt },
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (expiresAtDate.getTime() <= approvedAtDate.getTime()) {
|
|
313
|
-
throw new ReleaseError(
|
|
314
|
-
GATE_FAILED,
|
|
315
|
-
`expiresAt (${approval.expiresAt}) must be after approvedAt (${approval.approvedAt})`,
|
|
316
|
-
{ approvedAt: approval.approvedAt, expiresAt: approval.expiresAt },
|
|
317
|
-
);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// --- Max 24h approval window ---
|
|
321
|
-
const MAX_APPROVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
322
|
-
const approvalDurationMs = expiresAtDate.getTime() - approvedAtDate.getTime();
|
|
323
|
-
if (approvalDurationMs > MAX_APPROVAL_MS) {
|
|
324
|
-
throw new ReleaseError(
|
|
325
|
-
GATE_FAILED,
|
|
326
|
-
`approval duration ${Math.round(approvalDurationMs / 3600000)}h exceeds maximum 24h`,
|
|
327
|
-
{ approvedAt: approval.approvedAt, expiresAt: approval.expiresAt, durationHours: approvalDurationMs / 3600000 },
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// --- Reject future approvedAt (beyond 5-minute clock skew tolerance) ---
|
|
332
|
-
const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes
|
|
333
|
-
const now = clockFn();
|
|
334
|
-
const nowDate = new Date(now);
|
|
335
|
-
if (Number.isNaN(nowDate.getTime())) {
|
|
336
|
-
throw new ReleaseError(
|
|
337
|
-
GATE_FAILED,
|
|
338
|
-
`invalid clock value: "${now}"`,
|
|
339
|
-
{ clock: now },
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
if (approvedAtDate.getTime() > nowDate.getTime() + CLOCK_SKEW_TOLERANCE_MS) {
|
|
343
|
-
throw new ReleaseError(
|
|
344
|
-
GATE_FAILED,
|
|
345
|
-
`approvedAt (${approval.approvedAt}) is in the future (current time: ${now})`,
|
|
346
|
-
{ approvedAt: approval.approvedAt, now },
|
|
347
|
-
);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// --- Expiry (publish/reconcile require current approval; verify may only
|
|
351
|
-
// revalidate the immutable approval identity after publication) ---
|
|
352
|
-
if (options.requireUnexpired !== false && nowDate > expiresAtDate) {
|
|
353
|
-
throw new ReleaseError(
|
|
354
|
-
GATE_FAILED,
|
|
355
|
-
`approval expired at ${approval.expiresAt}, current time is ${now}`,
|
|
356
|
-
{ expiresAt: approval.expiresAt, now },
|
|
357
|
-
);
|
|
358
|
-
}
|
|
379
|
+
// --- Time validation (shared authority: 24h window, 5-minute skew, expiry) ---
|
|
380
|
+
validateApprovalTimeWindow(approval, {
|
|
381
|
+
clock: options.clock,
|
|
382
|
+
requireUnexpired: options.requireUnexpired,
|
|
383
|
+
});
|
|
359
384
|
|
|
360
385
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded hook-output tails (v0.6.3 R1 tail unification).
|
|
3
|
+
*
|
|
4
|
+
* Single authority for the "last 50 lines, capped at 8 KiB" tail semantics
|
|
5
|
+
* used everywhere a captured child-output stream is persisted as evidence:
|
|
6
|
+
* prepare hook failures (original home) and distribute postPublish hook
|
|
7
|
+
* failures (R1; previously a 4000-character slice). Keeping both call sites
|
|
8
|
+
* on one implementation means triage output can never diverge between the
|
|
9
|
+
* prepare and distribute phases.
|
|
10
|
+
*
|
|
11
|
+
* @module core/bounded-output
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Maximum number of output lines preserved in a hook-failure tail. */
|
|
15
|
+
export const HOOK_OUTPUT_TAIL_MAX_LINES = 50;
|
|
16
|
+
/** Maximum bytes preserved in a hook-failure tail. */
|
|
17
|
+
export const HOOK_OUTPUT_TAIL_MAX_BYTES = 8 * 1024;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Bound a captured child-output stream to the tail that matters for triage:
|
|
21
|
+
* the last 50 lines, further capped at 8 KB — whichever is smaller.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} [text] - Captured stdout/stderr text.
|
|
24
|
+
* @returns {string} The bounded tail ('' for empty/absent input).
|
|
25
|
+
*/
|
|
26
|
+
export function boundedOutputTail(text) {
|
|
27
|
+
if (typeof text !== 'string' || text.length === 0) return '';
|
|
28
|
+
let lines = text.split('\n');
|
|
29
|
+
// A trailing newline produces an empty final element; drop it so the line
|
|
30
|
+
// budget counts real output lines.
|
|
31
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') {
|
|
32
|
+
lines = lines.slice(0, -1);
|
|
33
|
+
}
|
|
34
|
+
let tail = lines.slice(-HOOK_OUTPUT_TAIL_MAX_LINES);
|
|
35
|
+
let joined = tail.join('\n');
|
|
36
|
+
while (tail.length > 1 && Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
|
|
37
|
+
tail = tail.slice(1);
|
|
38
|
+
joined = tail.join('\n');
|
|
39
|
+
}
|
|
40
|
+
if (Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
|
|
41
|
+
// A single line exceeds the byte cap: keep the trailing bytes.
|
|
42
|
+
const buf = Buffer.from(joined, 'utf8');
|
|
43
|
+
joined = buf.subarray(buf.length - HOOK_OUTPUT_TAIL_MAX_BYTES).toString('utf8');
|
|
44
|
+
}
|
|
45
|
+
return joined;
|
|
46
|
+
}
|