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
|
@@ -61,11 +61,19 @@ import {
|
|
|
61
61
|
} from '../core/run.mjs';
|
|
62
62
|
import { createEvidenceWriter } from '../core/evidence.mjs';
|
|
63
63
|
import { runHook } from '../core/hooks.mjs';
|
|
64
|
+
import { boundedOutputTail } from '../core/bounded-output.mjs';
|
|
64
65
|
import {
|
|
65
66
|
orderTargetsByDependency,
|
|
66
67
|
validatePostPublishDeclaration,
|
|
68
|
+
buildPostPublishContext,
|
|
69
|
+
normalizePostPublishDeclaration,
|
|
70
|
+
orderNormalizedHooks,
|
|
71
|
+
effectiveHookRequiresApproval,
|
|
72
|
+
POSTPUBLISH_CONTEXT_ENV,
|
|
67
73
|
PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
68
74
|
} from '../core/postpublish.mjs';
|
|
75
|
+
import { validatePostPublishApproval } from '../core/postpublish-approval.mjs';
|
|
76
|
+
import { executePresetHook } from '../core/preset-executor.mjs';
|
|
69
77
|
import {
|
|
70
78
|
ReleaseError,
|
|
71
79
|
GATE_FAILED,
|
|
@@ -80,9 +88,6 @@ const execFileAsync = promisify(execFileCb);
|
|
|
80
88
|
/** Executor identity recorded in every distribute checkpoint trace. */
|
|
81
89
|
const EXECUTOR = 'release-skill distribute';
|
|
82
90
|
|
|
83
|
-
/** Tail length for hook stdout/stderr recorded in evidence. */
|
|
84
|
-
const TAIL_CHARS = 4000;
|
|
85
|
-
|
|
86
91
|
/** Full 40-hex commit sha. */
|
|
87
92
|
const SHA_RE = /^[a-f0-9]{40}$/;
|
|
88
93
|
|
|
@@ -105,6 +110,7 @@ const DISTRIBUTING = 'DISTRIBUTING';
|
|
|
105
110
|
const DISTRIBUTED = 'DISTRIBUTED';
|
|
106
111
|
const PARTIAL = 'PARTIAL';
|
|
107
112
|
const BLOCKED = 'BLOCKED';
|
|
113
|
+
const NEEDS_INPUT = 'NEEDS_INPUT';
|
|
108
114
|
|
|
109
115
|
function defaultClock() {
|
|
110
116
|
return new Date().toISOString();
|
|
@@ -114,11 +120,6 @@ function defaultExec(command, args, options = {}) {
|
|
|
114
120
|
return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: 120_000, ...options });
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
function tail(text, limit = TAIL_CHARS) {
|
|
118
|
-
const value = `${text ?? ''}`;
|
|
119
|
-
return value.length > limit ? value.slice(-limit) : value;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
123
|
/** Map an adapter/details error code onto the run-schema checkpoint enum. */
|
|
123
124
|
function mapToSchemaCode(code) {
|
|
124
125
|
return SCHEMA_ERROR_CODES.has(code) ? code : GATE_FAILED;
|
|
@@ -230,6 +231,9 @@ function resolvePluginName(plan, unitId) {
|
|
|
230
231
|
* @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
|
|
231
232
|
* @param {Function} [options.execFn] - Injectable git exec (tests).
|
|
232
233
|
* @param {Function} [options.runHookFn] - Injectable hook runner (tests).
|
|
234
|
+
* @param {string[]} [options.postpublishApprovalPaths] - Checkpoint approval
|
|
235
|
+
* records for requiresApproval postPublish hooks (v0.6.3 R1); each record
|
|
236
|
+
* binds (planDigest, hookId) and is validated fail-closed before any write.
|
|
233
237
|
*
|
|
234
238
|
* @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
|
|
235
239
|
*
|
|
@@ -248,6 +252,7 @@ export async function distributeRelease(options) {
|
|
|
248
252
|
clock: clockOpt,
|
|
249
253
|
execFn,
|
|
250
254
|
runHookFn,
|
|
255
|
+
postpublishApprovalPaths,
|
|
251
256
|
} = options ?? {};
|
|
252
257
|
|
|
253
258
|
const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
|
|
@@ -407,7 +412,16 @@ export async function distributeRelease(options) {
|
|
|
407
412
|
?? computeApprovalDigest(approvalRaw);
|
|
408
413
|
|
|
409
414
|
validateApprovalRecordSchema(approval);
|
|
410
|
-
|
|
415
|
+
// distribute is a post-publish phase: like reconcile/verify it validates
|
|
416
|
+
// the approval binding (planDigest/approvedActions/digest) without
|
|
417
|
+
// requiring an unexpired window — distribution may legitimately happen
|
|
418
|
+
// after the 24h publish-approval window. requiresApproval hook approvals
|
|
419
|
+
// remain expiry-enforced by validatePostPublishApproval.
|
|
420
|
+
// requireUnexpired: false aligns with reconcile.mjs:321 and
|
|
421
|
+
// verify.mjs:1098; the binding checks (plan digest, approved actions,
|
|
422
|
+
// approval digest) are NOT relaxed here — only the expiry window is
|
|
423
|
+
// waived for the post-publish phase.
|
|
424
|
+
validateApproval(plan, approval, { clock: clockFn, requireUnexpired: false });
|
|
411
425
|
|
|
412
426
|
await evidence.append({ phase: 'safety-gate', gate: 'approval-validated', status: 'passed' });
|
|
413
427
|
|
|
@@ -448,12 +462,110 @@ export async function distributeRelease(options) {
|
|
|
448
462
|
|
|
449
463
|
// =======================================================================
|
|
450
464
|
// Declaration re-validation + deterministic target ordering.
|
|
465
|
+
// R2: preset references resolve against the built-in preset registry
|
|
466
|
+
// (core/presets.mjs); per-preset config validation (dual addressing,
|
|
467
|
+
// marketplace/staticFiles shapes, secret scan) fails closed here.
|
|
451
468
|
// =======================================================================
|
|
452
469
|
const postPublish = plan.postPublish;
|
|
453
470
|
validatePostPublishDeclaration(postPublish, { unitId: postPublish.unitId });
|
|
454
|
-
const orderedTargets = orderTargetsByDependency(postPublish.targets);
|
|
471
|
+
const orderedTargets = orderTargetsByDependency(postPublish.targets ?? []);
|
|
472
|
+
|
|
473
|
+
// Normalized hook table (design §2.2): every targets[] entry maps onto a
|
|
474
|
+
// preset hook (payload-mirror -> git-mirror, marketplace-index ->
|
|
475
|
+
// marketplace-index-render); the table is a deterministic projection of
|
|
476
|
+
// the digest-bound declaration, ordered by dependency topology +
|
|
477
|
+
// declaration order. Target execution below keeps the exact legacy
|
|
478
|
+
// semantics; hooks[] preset execution dispatches in the hook loop
|
|
479
|
+
// (proposal-inbox git-push is wired; other presets fail closed until
|
|
480
|
+
// their behavior ships).
|
|
481
|
+
const normalizedDeclaration = normalizePostPublishDeclaration(postPublish);
|
|
482
|
+
const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
|
|
483
|
+
await evidence.append({
|
|
484
|
+
phase: 'postpublish-normalization',
|
|
485
|
+
status: 'passed',
|
|
486
|
+
preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
|
|
487
|
+
hookCount: orderedNormalizedHooks.length,
|
|
488
|
+
hookIds: orderedNormalizedHooks.map((hook) => hook.id),
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// postPublish hooks (v0.6.3 R1): distribute-phase hooks run in this saga;
|
|
492
|
+
// postVerify-phase hooks belong to the independent postVerify run (R3)
|
|
493
|
+
// and are only evidenced here — never executed, never silent.
|
|
494
|
+
const declaredHooks = postPublish.hooks ?? [];
|
|
495
|
+
const distributeHooks = declaredHooks.filter((hook) => (hook.phase ?? 'distribute') === 'distribute');
|
|
496
|
+
const deferredPostVerifyHooks = declaredHooks.length - distributeHooks.length;
|
|
497
|
+
|
|
498
|
+
/** Gate failure after lineage is known: persist BLOCKED, then rethrow. */
|
|
499
|
+
const failBlocked = async (error) => {
|
|
500
|
+
await recordBlocked();
|
|
501
|
+
throw error;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// =======================================================================
|
|
505
|
+
// Gate: checkpoint approvals for requiresApproval hooks. Every provided
|
|
506
|
+
// record is validated fail-closed BEFORE any write (planDigest binding,
|
|
507
|
+
// declared hook, requiresApproval, 24h window, expiry). A bad approval
|
|
508
|
+
// aborts the whole saga; a missing one parks the hook at AWAITING_APPROVAL.
|
|
509
|
+
// =======================================================================
|
|
510
|
+
const approvedHookIds = new Set();
|
|
511
|
+
const hookApprovalPaths = postpublishApprovalPaths ?? [];
|
|
512
|
+
if (hookApprovalPaths.length > 0) {
|
|
513
|
+
await evidence.append({
|
|
514
|
+
phase: 'safety-gate',
|
|
515
|
+
gate: 'postpublish-hook-approvals',
|
|
516
|
+
status: 'started',
|
|
517
|
+
approvalCount: hookApprovalPaths.length,
|
|
518
|
+
});
|
|
519
|
+
for (const hookApprovalPath of hookApprovalPaths) {
|
|
520
|
+
let hookApprovalRaw;
|
|
521
|
+
try {
|
|
522
|
+
hookApprovalRaw = await readFile(hookApprovalPath, 'utf8');
|
|
523
|
+
} catch (err) {
|
|
524
|
+
await failBlocked(new ReleaseError(
|
|
525
|
+
GATE_FAILED,
|
|
526
|
+
`cannot read postpublish hook approval: ${err.message}`,
|
|
527
|
+
{ hookApprovalPath, cause: err.code },
|
|
528
|
+
));
|
|
529
|
+
}
|
|
530
|
+
let hookApproval;
|
|
531
|
+
try {
|
|
532
|
+
hookApproval = JSON.parse(hookApprovalRaw);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
await failBlocked(new ReleaseError(
|
|
535
|
+
GATE_FAILED,
|
|
536
|
+
`postpublish hook approval is not valid JSON: ${err.message}`,
|
|
537
|
+
{ hookApprovalPath },
|
|
538
|
+
));
|
|
539
|
+
}
|
|
540
|
+
validatePostPublishApproval(plan, hookApproval, { clock: clockFn });
|
|
541
|
+
if (approvedHookIds.has(hookApproval.hookId)) {
|
|
542
|
+
await failBlocked(new ReleaseError(
|
|
543
|
+
GATE_FAILED,
|
|
544
|
+
`duplicate postpublish hook approvals for hook "${hookApproval.hookId}"`,
|
|
545
|
+
{ hookId: hookApproval.hookId },
|
|
546
|
+
));
|
|
547
|
+
}
|
|
548
|
+
approvedHookIds.add(hookApproval.hookId);
|
|
549
|
+
}
|
|
550
|
+
await evidence.append({
|
|
551
|
+
phase: 'safety-gate',
|
|
552
|
+
gate: 'postpublish-hook-approvals',
|
|
553
|
+
status: 'passed',
|
|
554
|
+
approvedHookIds: [...approvedHookIds],
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Hooks whose checkpoint approval is still missing. While any exist, the
|
|
559
|
+
// declared postPublish steps (unaudited project code) must not execute:
|
|
560
|
+
// the run parks at NEEDS_INPUT/PARTIAL and the approved reconcile rerun
|
|
561
|
+
// re-executes them. Targets remain plan-approval-authorized idempotent
|
|
562
|
+
// remote-state convergence and are unaffected.
|
|
563
|
+
const pendingHookApprovals = distributeHooks.filter(
|
|
564
|
+
(hook) => effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id),
|
|
565
|
+
);
|
|
455
566
|
|
|
456
|
-
// Checkpoint registry: one probe + one mirror per target, declared order
|
|
567
|
+
// Checkpoint registry: one probe + one mirror per target, declared order,
|
|
568
|
+
// then one postpublish-hook checkpoint per distribute-phase hook.
|
|
457
569
|
checkpoints = [];
|
|
458
570
|
for (const target of orderedTargets) {
|
|
459
571
|
checkpoints.push({
|
|
@@ -478,17 +590,19 @@ export async function distributeRelease(options) {
|
|
|
478
590
|
executor: EXECUTOR,
|
|
479
591
|
});
|
|
480
592
|
}
|
|
593
|
+
for (const hook of distributeHooks) {
|
|
594
|
+
checkpoints.push({
|
|
595
|
+
actionId: hook.id,
|
|
596
|
+
actionType: 'postpublish-hook',
|
|
597
|
+
status: 'PENDING',
|
|
598
|
+
executor: EXECUTOR,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
481
601
|
const checkpointById = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
|
|
482
602
|
|
|
483
603
|
// Durable pre-execute authority (seq 0).
|
|
484
604
|
await snapshot(DISTRIBUTING);
|
|
485
605
|
|
|
486
|
-
/** Gate failure after lineage is known: persist BLOCKED, then rethrow. */
|
|
487
|
-
const failBlocked = async (error) => {
|
|
488
|
-
await recordBlocked();
|
|
489
|
-
throw error;
|
|
490
|
-
};
|
|
491
|
-
|
|
492
606
|
// =======================================================================
|
|
493
607
|
// Gate 4: tag identity — the live tag must still point at the frozen
|
|
494
608
|
// tagCommit. A missing binding or a moved tag fails closed.
|
|
@@ -518,7 +632,7 @@ export async function distributeRelease(options) {
|
|
|
518
632
|
phase: 'safety-gate',
|
|
519
633
|
gate: 'tag-identity',
|
|
520
634
|
status: 'failed',
|
|
521
|
-
error:
|
|
635
|
+
error: boundedOutputTail(err?.stderr ?? err?.message),
|
|
522
636
|
});
|
|
523
637
|
await failBlocked(new ReleaseError(
|
|
524
638
|
GATE_FAILED,
|
|
@@ -663,7 +777,7 @@ export async function distributeRelease(options) {
|
|
|
663
777
|
worktreePath = join(tmpBase, 'worktree');
|
|
664
778
|
await exec('git', ['-C', root, 'worktree', 'add', '--detach', worktreePath, postPublish.tagCommit]);
|
|
665
779
|
} catch (err) {
|
|
666
|
-
await evidence.append({ phase: 'worktree', status: 'failed', error:
|
|
780
|
+
await evidence.append({ phase: 'worktree', status: 'failed', error: boundedOutputTail(err?.stderr ?? err?.message) });
|
|
667
781
|
await failBlocked(new ReleaseError(
|
|
668
782
|
GATE_FAILED,
|
|
669
783
|
`cannot create the detached tag worktree at ${postPublish.tagCommit}: ${err?.message ?? err}`,
|
|
@@ -694,13 +808,13 @@ export async function distributeRelease(options) {
|
|
|
694
808
|
phase: 'materialize',
|
|
695
809
|
status: 'failed',
|
|
696
810
|
exitCode: hookResult.exitCode,
|
|
697
|
-
stdoutTail:
|
|
698
|
-
stderrTail:
|
|
811
|
+
stdoutTail: boundedOutputTail(hookResult.stdout),
|
|
812
|
+
stderrTail: boundedOutputTail(hookResult.stderr),
|
|
699
813
|
});
|
|
700
814
|
await failBlocked(new ReleaseError(
|
|
701
815
|
POST_PUBLISH_VERIFY_FAILED,
|
|
702
816
|
`materialize hook exited with code ${hookResult.exitCode}; payload cannot be trusted`,
|
|
703
|
-
{ exitCode: hookResult.exitCode, stdoutTail:
|
|
817
|
+
{ exitCode: hookResult.exitCode, stdoutTail: boundedOutputTail(hookResult.stdout), stderrTail: boundedOutputTail(hookResult.stderr) },
|
|
704
818
|
));
|
|
705
819
|
}
|
|
706
820
|
|
|
@@ -712,12 +826,12 @@ export async function distributeRelease(options) {
|
|
|
712
826
|
phase: 'materialize',
|
|
713
827
|
status: 'failed',
|
|
714
828
|
reason: 'report-missing',
|
|
715
|
-
stdoutTail:
|
|
829
|
+
stdoutTail: boundedOutputTail(hookResult.stdout),
|
|
716
830
|
});
|
|
717
831
|
await failBlocked(new ReleaseError(
|
|
718
832
|
POST_PUBLISH_VERIFY_FAILED,
|
|
719
833
|
'materialize report missing: no JSON object found on stdout (requireReport.parse = stdout-first-json)',
|
|
720
|
-
{ stdoutTail:
|
|
834
|
+
{ stdoutTail: boundedOutputTail(hookResult.stdout) },
|
|
721
835
|
));
|
|
722
836
|
}
|
|
723
837
|
const equals = materialize.requireReport.equals ?? {};
|
|
@@ -747,12 +861,12 @@ export async function distributeRelease(options) {
|
|
|
747
861
|
phase: 'materialize',
|
|
748
862
|
status: 'failed',
|
|
749
863
|
reason: 'marker-missing',
|
|
750
|
-
stdoutTail:
|
|
864
|
+
stdoutTail: boundedOutputTail(hookResult.stdout),
|
|
751
865
|
});
|
|
752
866
|
await failBlocked(new ReleaseError(
|
|
753
867
|
POST_PUBLISH_VERIFY_FAILED,
|
|
754
868
|
`materialize output marker "${materialize.outputMarker}" not found on stdout; payload directory unbound`,
|
|
755
|
-
{ stdoutTail:
|
|
869
|
+
{ stdoutTail: boundedOutputTail(hookResult.stdout) },
|
|
756
870
|
));
|
|
757
871
|
}
|
|
758
872
|
const payloadReal = await assertContainedDirectory(
|
|
@@ -766,6 +880,25 @@ export async function distributeRelease(options) {
|
|
|
766
880
|
// Declared postPublish steps, in order (fail-closed).
|
|
767
881
|
// =======================================================================
|
|
768
882
|
for (const step of postPublish.steps ?? []) {
|
|
883
|
+
if (dryRun === true) {
|
|
884
|
+
// R1 dry-run contract: steps are arbitrary project code with
|
|
885
|
+
// potentially remote side effects; they never execute in a rehearsal.
|
|
886
|
+
await evidence.append({ phase: 'postpublish-step', step: step.name, status: 'skipped', reason: 'DRY_RUN' });
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (pendingHookApprovals.length > 0) {
|
|
890
|
+
// A requiresApproval hook is still unapproved: the step pipeline must
|
|
891
|
+
// not execute before the checkpoint approval exists; the approved
|
|
892
|
+
// reconcile rerun re-executes the steps (idempotence-by-default).
|
|
893
|
+
await evidence.append({
|
|
894
|
+
phase: 'postpublish-step',
|
|
895
|
+
step: step.name,
|
|
896
|
+
status: 'skipped',
|
|
897
|
+
reason: 'AWAITING_CHECKPOINT_APPROVAL',
|
|
898
|
+
pendingHookApprovals: pendingHookApprovals.map((hook) => hook.id),
|
|
899
|
+
});
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
769
902
|
await evidence.append({ phase: 'postpublish-step', step: step.name, status: 'started' });
|
|
770
903
|
const stepResult = await hookRunner(
|
|
771
904
|
{
|
|
@@ -782,8 +915,8 @@ export async function distributeRelease(options) {
|
|
|
782
915
|
step: step.name,
|
|
783
916
|
status: 'failed',
|
|
784
917
|
exitCode: stepResult.exitCode,
|
|
785
|
-
stdoutTail:
|
|
786
|
-
stderrTail:
|
|
918
|
+
stdoutTail: boundedOutputTail(stepResult.stdout),
|
|
919
|
+
stderrTail: boundedOutputTail(stepResult.stderr),
|
|
787
920
|
});
|
|
788
921
|
await failBlocked(new ReleaseError(
|
|
789
922
|
GATE_FAILED,
|
|
@@ -1022,15 +1155,260 @@ export async function distributeRelease(options) {
|
|
|
1022
1155
|
}
|
|
1023
1156
|
|
|
1024
1157
|
// =======================================================================
|
|
1025
|
-
//
|
|
1158
|
+
// postPublish hooks (distribute phase), executed AFTER the target writes.
|
|
1159
|
+
// Contract (design §2.3): the read-only frozen-plan projection travels via
|
|
1160
|
+
// RELEASE_SKILL_POSTPUBLISH_CONTEXT; hooks run inside the frozen tag
|
|
1161
|
+
// worktree; a failure stops the hook chain; a requiresApproval hook
|
|
1162
|
+
// without a checkpoint approval parks at AWAITING_APPROVAL and never
|
|
1163
|
+
// executes; dry-run executes nothing; postVerify-phase hooks belong to
|
|
1164
|
+
// the independent postVerify run (R3) and are evidenced as deferred.
|
|
1165
|
+
// =======================================================================
|
|
1166
|
+
if (deferredPostVerifyHooks > 0) {
|
|
1167
|
+
await evidence.append({ phase: 'postpublish-hooks', deferredPostVerifyHooks });
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
let hooksStopped = stopped;
|
|
1171
|
+
let awaitingApproval = 0;
|
|
1172
|
+
let hookSuccesses = 0;
|
|
1173
|
+
const hookContextProjection = buildPostPublishContext({
|
|
1174
|
+
plan,
|
|
1175
|
+
runId,
|
|
1176
|
+
sourceRun,
|
|
1177
|
+
payloadDir: payloadReal,
|
|
1178
|
+
phase: 'distribute',
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
// Proposal documents must stay byte-deterministic across redeliveries of
|
|
1182
|
+
// the SAME release event (NO_CHANGE idempotence on reconcile reruns):
|
|
1183
|
+
// they travel with the stable lineage-derived event identity, not the
|
|
1184
|
+
// per-attempt runId.
|
|
1185
|
+
const proposalContextProjection = {
|
|
1186
|
+
...hookContextProjection,
|
|
1187
|
+
runId: `distribute-${sourceRunId}`,
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
for (const hook of distributeHooks) {
|
|
1191
|
+
const cp = checkpointById.get(hook.id);
|
|
1192
|
+
cp.startedAt = clockFn();
|
|
1193
|
+
|
|
1194
|
+
if (hooksStopped) {
|
|
1195
|
+
cp.status = 'SKIPPED';
|
|
1196
|
+
cp.reason = 'EARLIER_TARGET_FAILED';
|
|
1197
|
+
cp.finishedAt = clockFn();
|
|
1198
|
+
await evidence.append({
|
|
1199
|
+
phase: 'postpublish-hook',
|
|
1200
|
+
hookId: hook.id,
|
|
1201
|
+
status: 'skipped',
|
|
1202
|
+
reason: 'EARLIER_TARGET_FAILED',
|
|
1203
|
+
});
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (dryRun === true) {
|
|
1208
|
+
cp.status = 'SKIPPED';
|
|
1209
|
+
cp.reason = 'DRY_RUN';
|
|
1210
|
+
cp.finishedAt = clockFn();
|
|
1211
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'skipped', reason: 'DRY_RUN' });
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id)) {
|
|
1216
|
+
// No checkpoint approval: the hook must not execute. It parks (does
|
|
1217
|
+
// not stop the chain — later reconcile reruns retry it once approved).
|
|
1218
|
+
cp.status = 'AWAITING_APPROVAL';
|
|
1219
|
+
cp.finishedAt = clockFn();
|
|
1220
|
+
awaitingApproval += 1;
|
|
1221
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'awaiting-approval' });
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// -----------------------------------------------------------------
|
|
1226
|
+
// Preset hooks dispatch through the R4 preset executor (one seam for
|
|
1227
|
+
// every registered preset; fail-closed wording for presets registered
|
|
1228
|
+
// but not yet shipped).
|
|
1229
|
+
// -----------------------------------------------------------------
|
|
1230
|
+
if (hook.preset !== undefined) {
|
|
1231
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
|
|
1232
|
+
let delivery;
|
|
1233
|
+
try {
|
|
1234
|
+
delivery = await executePresetHook({
|
|
1235
|
+
hook,
|
|
1236
|
+
contextProjection: hookContextProjection,
|
|
1237
|
+
proposalContextProjection,
|
|
1238
|
+
commitIdentity: postPublish.commitIdentity,
|
|
1239
|
+
root: worktreePath,
|
|
1240
|
+
evidencePath: join(runDir, 'evidence.jsonl'),
|
|
1241
|
+
payloadDir: hookContextProjection.payloadDir,
|
|
1242
|
+
exec,
|
|
1243
|
+
hookRunner,
|
|
1244
|
+
});
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
const code = mapToSchemaCode(err?.code);
|
|
1247
|
+
cp.status = 'FAILED';
|
|
1248
|
+
cp.error = { code, message: err?.message ?? String(err) };
|
|
1249
|
+
cp.finishedAt = clockFn();
|
|
1250
|
+
failures += 1;
|
|
1251
|
+
hooksStopped = true;
|
|
1252
|
+
await evidence.append({
|
|
1253
|
+
phase: 'postpublish-hook',
|
|
1254
|
+
hookId: hook.id,
|
|
1255
|
+
status: 'failed',
|
|
1256
|
+
error: err?.message ?? String(err),
|
|
1257
|
+
details: { code },
|
|
1258
|
+
});
|
|
1259
|
+
await snapshot(PARTIAL);
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
if (delivery.status === 'NO_CHANGE') {
|
|
1264
|
+
cp.status = 'NO_CHANGE';
|
|
1265
|
+
cp.mode = 'no-change';
|
|
1266
|
+
cp.finishedAt = clockFn();
|
|
1267
|
+
hookSuccesses += 1;
|
|
1268
|
+
await evidence.append({
|
|
1269
|
+
phase: 'postpublish-hook',
|
|
1270
|
+
hookId: hook.id,
|
|
1271
|
+
status: 'no-change',
|
|
1272
|
+
...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
|
|
1273
|
+
// §2.6 execution realpath evidence (R4 review m-2).
|
|
1274
|
+
...(delivery.observation?.workspaceRealpath
|
|
1275
|
+
? { workspaceRealpath: delivery.observation.workspaceRealpath }
|
|
1276
|
+
: {}),
|
|
1277
|
+
...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
|
|
1278
|
+
});
|
|
1279
|
+
await snapshot(PARTIAL);
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
cp.status = 'SUCCEEDED';
|
|
1284
|
+
if (delivery.observation?.mode === 'pushed' && delivery.observation?.pushedCommit) {
|
|
1285
|
+
cp.mode = 'pushed';
|
|
1286
|
+
cp.pushedCommit = delivery.observation.pushedCommit;
|
|
1287
|
+
}
|
|
1288
|
+
cp.finishedAt = clockFn();
|
|
1289
|
+
hookSuccesses += 1;
|
|
1290
|
+
await evidence.append({
|
|
1291
|
+
phase: 'postpublish-hook',
|
|
1292
|
+
hookId: hook.id,
|
|
1293
|
+
status: 'succeeded',
|
|
1294
|
+
preset: hook.preset,
|
|
1295
|
+
mode: delivery.mode ?? delivery.observation?.mode,
|
|
1296
|
+
...(delivery.observation?.pushedCommit ? { pushedCommit: delivery.observation.pushedCommit } : {}),
|
|
1297
|
+
...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
|
|
1298
|
+
...(delivery.checklist ? { checklist: delivery.checklist } : {}),
|
|
1299
|
+
...(delivery.degradedToNotifyHandoff === true ? { degradedToNotifyHandoff: true } : {}),
|
|
1300
|
+
...(Array.isArray(delivery.observations) ? { targets: delivery.observations } : {}),
|
|
1301
|
+
// §2.6 execution realpath evidence (R4 review m-2) + explicit
|
|
1302
|
+
// cross-check skip note (R4 review m-4).
|
|
1303
|
+
...(delivery.observation?.workspaceRealpath
|
|
1304
|
+
? { workspaceRealpath: delivery.observation.workspaceRealpath }
|
|
1305
|
+
: {}),
|
|
1306
|
+
...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
|
|
1307
|
+
...(delivery.observation?.crossCheck ? { crossCheck: delivery.observation.crossCheck } : {}),
|
|
1308
|
+
});
|
|
1309
|
+
await snapshot(PARTIAL);
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if (!Array.isArray(hook.command)) {
|
|
1314
|
+
// Fail-closed: a hook with no executable command stops the chain.
|
|
1315
|
+
cp.status = 'FAILED';
|
|
1316
|
+
cp.error = {
|
|
1317
|
+
code: POST_PUBLISH_VERIFY_FAILED,
|
|
1318
|
+
message: `hook "${hook.id}" has no executable command`,
|
|
1319
|
+
};
|
|
1320
|
+
cp.finishedAt = clockFn();
|
|
1321
|
+
failures += 1;
|
|
1322
|
+
hooksStopped = true;
|
|
1323
|
+
await evidence.append({
|
|
1324
|
+
phase: 'postpublish-hook',
|
|
1325
|
+
hookId: hook.id,
|
|
1326
|
+
status: 'failed',
|
|
1327
|
+
reason: 'no-command',
|
|
1328
|
+
});
|
|
1329
|
+
await snapshot(PARTIAL);
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
|
|
1334
|
+
let hookExecution;
|
|
1335
|
+
try {
|
|
1336
|
+
hookExecution = await hookRunner(
|
|
1337
|
+
{
|
|
1338
|
+
command: hook.command,
|
|
1339
|
+
...(hook.cwd ? { cwd: hook.cwd } : {}),
|
|
1340
|
+
...(hook.timeoutMs !== undefined ? { timeoutMs: hook.timeoutMs } : {}),
|
|
1341
|
+
...(hook.envAllowlist ? { envAllowlist: hook.envAllowlist } : {}),
|
|
1342
|
+
},
|
|
1343
|
+
{
|
|
1344
|
+
root: worktreePath,
|
|
1345
|
+
env: process.env,
|
|
1346
|
+
injectEnv: { [POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(hookContextProjection) },
|
|
1347
|
+
},
|
|
1348
|
+
);
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
// HOOK_TIMEOUT (or a runner defect): FAILED checkpoint, stop the chain.
|
|
1351
|
+
const code = err?.code === 'HOOK_TIMEOUT' ? 'HOOK_TIMEOUT' : POST_PUBLISH_VERIFY_FAILED;
|
|
1352
|
+
cp.status = 'FAILED';
|
|
1353
|
+
cp.error = { code, message: err?.message ?? String(err) };
|
|
1354
|
+
cp.finishedAt = clockFn();
|
|
1355
|
+
failures += 1;
|
|
1356
|
+
hooksStopped = true;
|
|
1357
|
+
await evidence.append({
|
|
1358
|
+
phase: 'postpublish-hook',
|
|
1359
|
+
hookId: hook.id,
|
|
1360
|
+
status: 'failed',
|
|
1361
|
+
error: err?.message ?? String(err),
|
|
1362
|
+
});
|
|
1363
|
+
await snapshot(PARTIAL);
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
if (hookExecution.exitCode !== 0) {
|
|
1368
|
+
cp.status = 'FAILED';
|
|
1369
|
+
cp.error = {
|
|
1370
|
+
code: POST_PUBLISH_VERIFY_FAILED,
|
|
1371
|
+
message: `postPublish hook "${hook.id}" exited with code ${hookExecution.exitCode}`,
|
|
1372
|
+
};
|
|
1373
|
+
cp.finishedAt = clockFn();
|
|
1374
|
+
failures += 1;
|
|
1375
|
+
hooksStopped = true;
|
|
1376
|
+
await evidence.append({
|
|
1377
|
+
phase: 'postpublish-hook',
|
|
1378
|
+
hookId: hook.id,
|
|
1379
|
+
status: 'failed',
|
|
1380
|
+
exitCode: hookExecution.exitCode,
|
|
1381
|
+
stdoutTail: boundedOutputTail(hookExecution.stdout),
|
|
1382
|
+
stderrTail: boundedOutputTail(hookExecution.stderr),
|
|
1383
|
+
});
|
|
1384
|
+
await snapshot(PARTIAL);
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
cp.status = 'SUCCEEDED';
|
|
1389
|
+
cp.finishedAt = clockFn();
|
|
1390
|
+
hookSuccesses += 1;
|
|
1391
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'succeeded' });
|
|
1392
|
+
await snapshot(PARTIAL);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// =======================================================================
|
|
1396
|
+
// Classification (returned, not thrown):
|
|
1397
|
+
// - DISTRIBUTED: no failures and no awaiting-approval hooks;
|
|
1398
|
+
// - NEEDS_INPUT: only awaiting-approval checkpoints and zero external
|
|
1399
|
+
// side effects so far (pure input-needed state, never PARTIAL);
|
|
1400
|
+
// - PARTIAL: at least one external success (pushed write or succeeded
|
|
1401
|
+
// hook) alongside failures or awaiting-approval checkpoints;
|
|
1402
|
+
// - BLOCKED: failures with zero external side effects landed.
|
|
1026
1403
|
// =======================================================================
|
|
1404
|
+
const externalCheckpointSuccesses = pushedWrites + hookSuccesses;
|
|
1027
1405
|
let overallStatus;
|
|
1028
|
-
if (failures === 0) {
|
|
1406
|
+
if (failures === 0 && awaitingApproval === 0) {
|
|
1029
1407
|
overallStatus = DISTRIBUTED;
|
|
1030
|
-
} else if (
|
|
1031
|
-
overallStatus = PARTIAL;
|
|
1408
|
+
} else if (failures === 0) {
|
|
1409
|
+
overallStatus = externalCheckpointSuccesses > 0 ? PARTIAL : NEEDS_INPUT;
|
|
1032
1410
|
} else {
|
|
1033
|
-
overallStatus = BLOCKED;
|
|
1411
|
+
overallStatus = externalCheckpointSuccesses > 0 ? PARTIAL : BLOCKED;
|
|
1034
1412
|
}
|
|
1035
1413
|
|
|
1036
1414
|
const finishedAt = clockFn();
|