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
|
@@ -7,9 +7,33 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Timing contract (R1): the payload may ONLY ever come from a detached git
|
|
9
9
|
* worktree checked out at the frozen `postPublish.tagCommit` — never from
|
|
10
|
-
* the live workspace, which has already moved ahead.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* the live workspace, which has already moved ahead. Since F-06 / T6 the
|
|
11
|
+
* frozen plan selects exactly one of two staging routes:
|
|
12
|
+
* - a consumer materialize hook (when declared): it runs inside that
|
|
13
|
+
* worktree and announces the isolated payload directory through its
|
|
14
|
+
* `outputMarker` line;
|
|
15
|
+
* - the Foundation managed projection (when the plan declares no materialize
|
|
16
|
+
* hook): the frozen `postPublish.executionBundle.publicFiles` mapping is
|
|
17
|
+
* staged from the tag worktree into a fresh `hub-payload` root through the
|
|
18
|
+
* Engineering Kit `compileProjectionPlan`/`runProjection` contract —
|
|
19
|
+
* never by live project configuration and never by a parent-workspace
|
|
20
|
+
* script.
|
|
21
|
+
*
|
|
22
|
+
* Private execution bundle (F-01 / T1): consumer-declared hook commands
|
|
23
|
+
* (materialize, steps, custom distribute-phase hooks) may reference scripts
|
|
24
|
+
* by workspace-relative paths that the frozen tag tree never contains —
|
|
25
|
+
* parent-workspace tooling never leaks into the public surface. prepare
|
|
26
|
+
* freezes those files (closed-world `executionFiles` manifest) into the
|
|
27
|
+
* plan's `postPublish.executionBundle` and publishes the bytes
|
|
28
|
+
* digest-addressed under the plan's `.release-skill`. Before ANY hook runs,
|
|
29
|
+
* distribute strictly re-reads the bundle bytes, recomputes the closure
|
|
30
|
+
* through Foundation, and installs ONLY the verified bytes into the tag
|
|
31
|
+
* worktree (never overwriting tag files). Live-workspace copying and the
|
|
32
|
+
* RELEASE_SKILL_WORKSPACE_ROOT injection are gone: after the freeze, the
|
|
33
|
+
* workspace copies are never read back. The payload timing contract is
|
|
34
|
+
* untouched: payload content must still be produced from the frozen
|
|
35
|
+
* checkout and the announced payload directory must stay inside the
|
|
36
|
+
* worktree.
|
|
13
37
|
*
|
|
14
38
|
* Safety gates (all verified before any adapter execute):
|
|
15
39
|
* 1. plan schema validation
|
|
@@ -61,11 +85,21 @@ import {
|
|
|
61
85
|
} from '../core/run.mjs';
|
|
62
86
|
import { createEvidenceWriter } from '../core/evidence.mjs';
|
|
63
87
|
import { runHook } from '../core/hooks.mjs';
|
|
88
|
+
import { boundedOutputTail } from '../core/bounded-output.mjs';
|
|
64
89
|
import {
|
|
65
90
|
orderTargetsByDependency,
|
|
66
91
|
validatePostPublishDeclaration,
|
|
92
|
+
buildPostPublishContext,
|
|
93
|
+
normalizePostPublishDeclaration,
|
|
94
|
+
orderNormalizedHooks,
|
|
95
|
+
effectiveHookRequiresApproval,
|
|
96
|
+
POSTPUBLISH_CONTEXT_ENV,
|
|
67
97
|
PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
68
98
|
} from '../core/postpublish.mjs';
|
|
99
|
+
import { verifyAndInstallExecutionBundle } from '../core/postpublish-bundle.mjs';
|
|
100
|
+
import { projectPublicPayload, PROJECTION_MECHANISM, PUBLIC_PAYLOAD_DIRNAME } from '../core/postpublish-projection.mjs';
|
|
101
|
+
import { assertPostPublishApprovalAuthority, validatePostPublishApproval } from '../core/postpublish-approval.mjs';
|
|
102
|
+
import { executePresetHook } from '../core/preset-executor.mjs';
|
|
69
103
|
import {
|
|
70
104
|
ReleaseError,
|
|
71
105
|
GATE_FAILED,
|
|
@@ -80,9 +114,6 @@ const execFileAsync = promisify(execFileCb);
|
|
|
80
114
|
/** Executor identity recorded in every distribute checkpoint trace. */
|
|
81
115
|
const EXECUTOR = 'release-skill distribute';
|
|
82
116
|
|
|
83
|
-
/** Tail length for hook stdout/stderr recorded in evidence. */
|
|
84
|
-
const TAIL_CHARS = 4000;
|
|
85
|
-
|
|
86
117
|
/** Full 40-hex commit sha. */
|
|
87
118
|
const SHA_RE = /^[a-f0-9]{40}$/;
|
|
88
119
|
|
|
@@ -105,6 +136,7 @@ const DISTRIBUTING = 'DISTRIBUTING';
|
|
|
105
136
|
const DISTRIBUTED = 'DISTRIBUTED';
|
|
106
137
|
const PARTIAL = 'PARTIAL';
|
|
107
138
|
const BLOCKED = 'BLOCKED';
|
|
139
|
+
const NEEDS_INPUT = 'NEEDS_INPUT';
|
|
108
140
|
|
|
109
141
|
function defaultClock() {
|
|
110
142
|
return new Date().toISOString();
|
|
@@ -114,11 +146,6 @@ function defaultExec(command, args, options = {}) {
|
|
|
114
146
|
return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: 120_000, ...options });
|
|
115
147
|
}
|
|
116
148
|
|
|
117
|
-
function tail(text, limit = TAIL_CHARS) {
|
|
118
|
-
const value = `${text ?? ''}`;
|
|
119
|
-
return value.length > limit ? value.slice(-limit) : value;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
149
|
/** Map an adapter/details error code onto the run-schema checkpoint enum. */
|
|
123
150
|
function mapToSchemaCode(code) {
|
|
124
151
|
return SCHEMA_ERROR_CODES.has(code) ? code : GATE_FAILED;
|
|
@@ -230,6 +257,11 @@ function resolvePluginName(plan, unitId) {
|
|
|
230
257
|
* @param {() => string} [options.clock] - Clock function returning ISO-8601 strings.
|
|
231
258
|
* @param {Function} [options.execFn] - Injectable git exec (tests).
|
|
232
259
|
* @param {Function} [options.runHookFn] - Injectable hook runner (tests).
|
|
260
|
+
* @param {string[]} [options.postpublishApprovalPaths] - Checkpoint approval
|
|
261
|
+
* records for requiresApproval postPublish hooks (v0.6.3 R1); each record
|
|
262
|
+
* binds (planDigest, hookId), must be consumed from the immutable
|
|
263
|
+
* digest-addressed authority minted by approvePostPublishHook (F-02), and
|
|
264
|
+
* is validated fail-closed before any write.
|
|
233
265
|
*
|
|
234
266
|
* @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
|
|
235
267
|
*
|
|
@@ -248,6 +280,7 @@ export async function distributeRelease(options) {
|
|
|
248
280
|
clock: clockOpt,
|
|
249
281
|
execFn,
|
|
250
282
|
runHookFn,
|
|
283
|
+
postpublishApprovalPaths,
|
|
251
284
|
} = options ?? {};
|
|
252
285
|
|
|
253
286
|
const clockFn = typeof clockOpt === 'function' ? clockOpt : defaultClock;
|
|
@@ -407,7 +440,16 @@ export async function distributeRelease(options) {
|
|
|
407
440
|
?? computeApprovalDigest(approvalRaw);
|
|
408
441
|
|
|
409
442
|
validateApprovalRecordSchema(approval);
|
|
410
|
-
|
|
443
|
+
// distribute is a post-publish phase: like reconcile/verify it validates
|
|
444
|
+
// the approval binding (planDigest/approvedActions/digest) without
|
|
445
|
+
// requiring an unexpired window — distribution may legitimately happen
|
|
446
|
+
// after the 24h publish-approval window. requiresApproval hook approvals
|
|
447
|
+
// remain expiry-enforced by validatePostPublishApproval.
|
|
448
|
+
// requireUnexpired: false aligns with reconcile.mjs:321 and
|
|
449
|
+
// verify.mjs:1098; the binding checks (plan digest, approved actions,
|
|
450
|
+
// approval digest) are NOT relaxed here — only the expiry window is
|
|
451
|
+
// waived for the post-publish phase.
|
|
452
|
+
validateApproval(plan, approval, { clock: clockFn, requireUnexpired: false });
|
|
411
453
|
|
|
412
454
|
await evidence.append({ phase: 'safety-gate', gate: 'approval-validated', status: 'passed' });
|
|
413
455
|
|
|
@@ -448,12 +490,127 @@ export async function distributeRelease(options) {
|
|
|
448
490
|
|
|
449
491
|
// =======================================================================
|
|
450
492
|
// Declaration re-validation + deterministic target ordering.
|
|
493
|
+
// R2: preset references resolve against the built-in preset registry
|
|
494
|
+
// (core/presets.mjs); per-preset config validation (dual addressing,
|
|
495
|
+
// marketplace/staticFiles shapes, secret scan) fails closed here.
|
|
451
496
|
// =======================================================================
|
|
452
497
|
const postPublish = plan.postPublish;
|
|
453
498
|
validatePostPublishDeclaration(postPublish, { unitId: postPublish.unitId });
|
|
454
|
-
const orderedTargets = orderTargetsByDependency(postPublish.targets);
|
|
499
|
+
const orderedTargets = orderTargetsByDependency(postPublish.targets ?? []);
|
|
500
|
+
|
|
501
|
+
// Normalized hook table (design §2.2): every targets[] entry maps onto a
|
|
502
|
+
// preset hook (payload-mirror -> git-mirror, marketplace-index ->
|
|
503
|
+
// marketplace-index-render); the table is a deterministic projection of
|
|
504
|
+
// the digest-bound declaration, ordered by dependency topology +
|
|
505
|
+
// declaration order. Target execution below keeps the exact legacy
|
|
506
|
+
// semantics; hooks[] preset execution dispatches in the hook loop
|
|
507
|
+
// (proposal-inbox git-push is wired; other presets fail closed until
|
|
508
|
+
// their behavior ships).
|
|
509
|
+
const normalizedDeclaration = normalizePostPublishDeclaration(postPublish);
|
|
510
|
+
const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
|
|
511
|
+
await evidence.append({
|
|
512
|
+
phase: 'postpublish-normalization',
|
|
513
|
+
status: 'passed',
|
|
514
|
+
preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
|
|
515
|
+
hookCount: orderedNormalizedHooks.length,
|
|
516
|
+
hookIds: orderedNormalizedHooks.map((hook) => hook.id),
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// postPublish hooks (v0.6.3 R1): distribute-phase hooks run in this saga;
|
|
520
|
+
// postVerify-phase hooks belong to the independent postVerify run (R3)
|
|
521
|
+
// and are only evidenced here — never executed, never silent.
|
|
522
|
+
const declaredHooks = postPublish.hooks ?? [];
|
|
523
|
+
const distributeHooks = declaredHooks.filter((hook) => (hook.phase ?? 'distribute') === 'distribute');
|
|
524
|
+
const deferredPostVerifyHooks = declaredHooks.length - distributeHooks.length;
|
|
525
|
+
|
|
526
|
+
/** Gate failure after lineage is known: persist BLOCKED, then rethrow. */
|
|
527
|
+
const failBlocked = async (error) => {
|
|
528
|
+
await recordBlocked();
|
|
529
|
+
throw error;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
// =======================================================================
|
|
533
|
+
// Gate: checkpoint approvals for requiresApproval hooks. Every provided
|
|
534
|
+
// record is validated fail-closed BEFORE any write: first the immutable
|
|
535
|
+
// authority binding (F-02: the consumption path must BE the digest-
|
|
536
|
+
// addressed authority minted by approvePostPublishHook — recomputed
|
|
537
|
+
// planDigest directory, recomputed approvalDigest file name, strict
|
|
538
|
+
// no-follow regular-file read, no symlinked ancestor), then the content
|
|
539
|
+
// checks (schema, planDigest binding, declared hook, requiresApproval,
|
|
540
|
+
// 24h window, expiry). A bad approval aborts the whole saga; a missing
|
|
541
|
+
// one parks the hook at AWAITING_APPROVAL.
|
|
542
|
+
// =======================================================================
|
|
543
|
+
const approvedHookIds = new Set();
|
|
544
|
+
const hookApprovalPaths = postpublishApprovalPaths ?? [];
|
|
545
|
+
if (hookApprovalPaths.length > 0) {
|
|
546
|
+
await evidence.append({
|
|
547
|
+
phase: 'safety-gate',
|
|
548
|
+
gate: 'postpublish-hook-approvals',
|
|
549
|
+
status: 'started',
|
|
550
|
+
approvalCount: hookApprovalPaths.length,
|
|
551
|
+
});
|
|
552
|
+
for (const hookApprovalPath of hookApprovalPaths) {
|
|
553
|
+
let hookApprovalRaw;
|
|
554
|
+
try {
|
|
555
|
+
hookApprovalRaw = await readFile(hookApprovalPath, 'utf8');
|
|
556
|
+
} catch (err) {
|
|
557
|
+
await failBlocked(new ReleaseError(
|
|
558
|
+
GATE_FAILED,
|
|
559
|
+
`cannot read postpublish hook approval: ${err.message}`,
|
|
560
|
+
{ hookApprovalPath, cause: err.code },
|
|
561
|
+
));
|
|
562
|
+
}
|
|
563
|
+
let hookApproval;
|
|
564
|
+
try {
|
|
565
|
+
hookApproval = JSON.parse(hookApprovalRaw);
|
|
566
|
+
} catch (err) {
|
|
567
|
+
await failBlocked(new ReleaseError(
|
|
568
|
+
GATE_FAILED,
|
|
569
|
+
`postpublish hook approval is not valid JSON: ${err.message}`,
|
|
570
|
+
{ hookApprovalPath },
|
|
571
|
+
));
|
|
572
|
+
}
|
|
573
|
+
// F-02: identical bytes anywhere else are not an approval. The
|
|
574
|
+
// authority assertion runs before content validation and before the
|
|
575
|
+
// hook may enter approvedHookIds.
|
|
576
|
+
try {
|
|
577
|
+
await assertPostPublishApprovalAuthority(planPath, hookApprovalPath, plan, hookApprovalRaw);
|
|
578
|
+
} catch (err) {
|
|
579
|
+
await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
|
|
580
|
+
GATE_FAILED,
|
|
581
|
+
`postpublish hook approval authority check failed: ${err?.message ?? err}`,
|
|
582
|
+
{ hookApprovalPath },
|
|
583
|
+
));
|
|
584
|
+
}
|
|
585
|
+
validatePostPublishApproval(plan, hookApproval, { clock: clockFn });
|
|
586
|
+
if (approvedHookIds.has(hookApproval.hookId)) {
|
|
587
|
+
await failBlocked(new ReleaseError(
|
|
588
|
+
GATE_FAILED,
|
|
589
|
+
`duplicate postpublish hook approvals for hook "${hookApproval.hookId}"`,
|
|
590
|
+
{ hookId: hookApproval.hookId },
|
|
591
|
+
));
|
|
592
|
+
}
|
|
593
|
+
approvedHookIds.add(hookApproval.hookId);
|
|
594
|
+
}
|
|
595
|
+
await evidence.append({
|
|
596
|
+
phase: 'safety-gate',
|
|
597
|
+
gate: 'postpublish-hook-approvals',
|
|
598
|
+
status: 'passed',
|
|
599
|
+
approvedHookIds: [...approvedHookIds],
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Hooks whose checkpoint approval is still missing. While any exist, the
|
|
604
|
+
// declared postPublish steps (unaudited project code) must not execute:
|
|
605
|
+
// the run parks at NEEDS_INPUT/PARTIAL and the approved reconcile rerun
|
|
606
|
+
// re-executes them. Targets remain plan-approval-authorized idempotent
|
|
607
|
+
// remote-state convergence and are unaffected.
|
|
608
|
+
const pendingHookApprovals = distributeHooks.filter(
|
|
609
|
+
(hook) => effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id),
|
|
610
|
+
);
|
|
455
611
|
|
|
456
|
-
// Checkpoint registry: one probe + one mirror per target, declared order
|
|
612
|
+
// Checkpoint registry: one probe + one mirror per target, declared order,
|
|
613
|
+
// then one postpublish-hook checkpoint per distribute-phase hook.
|
|
457
614
|
checkpoints = [];
|
|
458
615
|
for (const target of orderedTargets) {
|
|
459
616
|
checkpoints.push({
|
|
@@ -478,17 +635,19 @@ export async function distributeRelease(options) {
|
|
|
478
635
|
executor: EXECUTOR,
|
|
479
636
|
});
|
|
480
637
|
}
|
|
638
|
+
for (const hook of distributeHooks) {
|
|
639
|
+
checkpoints.push({
|
|
640
|
+
actionId: hook.id,
|
|
641
|
+
actionType: 'postpublish-hook',
|
|
642
|
+
status: 'PENDING',
|
|
643
|
+
executor: EXECUTOR,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
481
646
|
const checkpointById = new Map(checkpoints.map((cp) => [cp.actionId, cp]));
|
|
482
647
|
|
|
483
648
|
// Durable pre-execute authority (seq 0).
|
|
484
649
|
await snapshot(DISTRIBUTING);
|
|
485
650
|
|
|
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
651
|
// =======================================================================
|
|
493
652
|
// Gate 4: tag identity — the live tag must still point at the frozen
|
|
494
653
|
// tagCommit. A missing binding or a moved tag fails closed.
|
|
@@ -518,7 +677,7 @@ export async function distributeRelease(options) {
|
|
|
518
677
|
phase: 'safety-gate',
|
|
519
678
|
gate: 'tag-identity',
|
|
520
679
|
status: 'failed',
|
|
521
|
-
error:
|
|
680
|
+
error: boundedOutputTail(err?.stderr ?? err?.message),
|
|
522
681
|
});
|
|
523
682
|
await failBlocked(new ReleaseError(
|
|
524
683
|
GATE_FAILED,
|
|
@@ -663,7 +822,7 @@ export async function distributeRelease(options) {
|
|
|
663
822
|
worktreePath = join(tmpBase, 'worktree');
|
|
664
823
|
await exec('git', ['-C', root, 'worktree', 'add', '--detach', worktreePath, postPublish.tagCommit]);
|
|
665
824
|
} catch (err) {
|
|
666
|
-
await evidence.append({ phase: 'worktree', status: 'failed', error:
|
|
825
|
+
await evidence.append({ phase: 'worktree', status: 'failed', error: boundedOutputTail(err?.stderr ?? err?.message) });
|
|
667
826
|
await failBlocked(new ReleaseError(
|
|
668
827
|
GATE_FAILED,
|
|
669
828
|
`cannot create the detached tag worktree at ${postPublish.tagCommit}: ${err?.message ?? err}`,
|
|
@@ -673,99 +832,243 @@ export async function distributeRelease(options) {
|
|
|
673
832
|
await evidence.append({ phase: 'worktree', status: 'passed' });
|
|
674
833
|
|
|
675
834
|
// =======================================================================
|
|
676
|
-
//
|
|
677
|
-
//
|
|
835
|
+
// Private execution bundle (F-01 / T1): consumer-declared commands may
|
|
836
|
+
// reference scripts that exist only in the parent workspace (tooling
|
|
837
|
+
// never leaks into the frozen public surface). prepare froze those bytes
|
|
838
|
+
// digest-addressed under the plan's .release-skill; re-verify them
|
|
839
|
+
// through Foundation and install ONLY the verified bytes into the tag
|
|
840
|
+
// worktree before any hook runs. Any mismatch fails closed here —
|
|
841
|
+
// before any hook and before any external write.
|
|
678
842
|
// =======================================================================
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
843
|
+
// =======================================================================
|
|
844
|
+
// F-04 root split: the saga holds TWO distinct roots from here on —
|
|
845
|
+
// - releaseWorkspaceRoot: the real project root the user releases from;
|
|
846
|
+
// only used to resolve preset target.workspace, compare the release-
|
|
847
|
+
// workspace write exclusion, and audit;
|
|
848
|
+
// - executionWorktreeRoot (worktreePath): the detached worktree at the
|
|
849
|
+
// frozen tagCommit; only used as the hook runner context.root for
|
|
850
|
+
// materialize, steps and custom command hooks.
|
|
851
|
+
// The two roots never fall back onto each other through defaults.
|
|
852
|
+
// =======================================================================
|
|
853
|
+
let releaseWorkspaceRoot;
|
|
854
|
+
try {
|
|
855
|
+
releaseWorkspaceRoot = await realpath(root);
|
|
856
|
+
} catch (err) {
|
|
857
|
+
await failBlocked(new ReleaseError(
|
|
858
|
+
GATE_FAILED,
|
|
859
|
+
`release workspace root does not resolve to an existing directory: ${err.message}`,
|
|
860
|
+
{ root, cause: err.code },
|
|
861
|
+
));
|
|
862
|
+
}
|
|
863
|
+
let installedBundlePaths = [];
|
|
864
|
+
try {
|
|
865
|
+
({ installed: installedBundlePaths } = await verifyAndInstallExecutionBundle({
|
|
866
|
+
plan,
|
|
867
|
+
planPath,
|
|
868
|
+
worktreePath,
|
|
869
|
+
}));
|
|
870
|
+
} catch (err) {
|
|
693
871
|
await evidence.append({
|
|
694
|
-
phase: '
|
|
872
|
+
phase: 'worktree',
|
|
873
|
+
gate: 'execution-bundle',
|
|
695
874
|
status: 'failed',
|
|
696
|
-
|
|
697
|
-
stdoutTail: tail(hookResult.stdout),
|
|
698
|
-
stderrTail: tail(hookResult.stderr),
|
|
875
|
+
error: boundedOutputTail(err?.message ?? String(err)),
|
|
699
876
|
});
|
|
700
|
-
await failBlocked(new ReleaseError(
|
|
701
|
-
|
|
702
|
-
`
|
|
703
|
-
{ exitCode: hookResult.exitCode, stdoutTail: tail(hookResult.stdout), stderrTail: tail(hookResult.stderr) },
|
|
877
|
+
await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
|
|
878
|
+
GATE_FAILED,
|
|
879
|
+
`cannot verify the frozen execution bundle: ${err?.message ?? err}`,
|
|
704
880
|
));
|
|
705
881
|
}
|
|
882
|
+
await evidence.append({
|
|
883
|
+
phase: 'worktree',
|
|
884
|
+
gate: 'execution-bundle',
|
|
885
|
+
status: 'passed',
|
|
886
|
+
installed: installedBundlePaths,
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// =======================================================================
|
|
890
|
+
// Payload staging (fail-closed). The frozen plan selects exactly one
|
|
891
|
+
// route (schema anyOf + runtime re-check):
|
|
892
|
+
// - consumer materialize hook: run it inside the tag worktree, verify
|
|
893
|
+
// its report, and bind the announced payload directory;
|
|
894
|
+
// - Foundation managed projection (F-06 / T6): when the plan declares
|
|
895
|
+
// no materialize hook, the frozen executionBundle.publicFiles mapping
|
|
896
|
+
// is staged from the tag worktree into a fresh hub-payload root by
|
|
897
|
+
// the Engineering Kit compileProjectionPlan/runProjection contract.
|
|
898
|
+
// The saga supplies ONLY the release-domain parameters (what is
|
|
899
|
+
// projected, where the frozen bytes live, where the payload lands);
|
|
900
|
+
// every preflight, containment check, transactional write, and
|
|
901
|
+
// rollback belongs to Foundation and is never reimplemented here.
|
|
902
|
+
// =======================================================================
|
|
903
|
+
let payloadReal;
|
|
904
|
+
const materialize = postPublish.materialize;
|
|
905
|
+
|
|
906
|
+
if (materialize !== undefined) {
|
|
907
|
+
await evidence.append({ phase: 'materialize', status: 'started' });
|
|
706
908
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
909
|
+
const hookResult = await hookRunner(
|
|
910
|
+
{
|
|
911
|
+
command: materialize.command,
|
|
912
|
+
...(materialize.cwd ? { cwd: materialize.cwd } : {}),
|
|
913
|
+
...(materialize.timeoutMs !== undefined ? { timeoutMs: materialize.timeoutMs } : {}),
|
|
914
|
+
...(materialize.envAllowlist ? { envAllowlist: materialize.envAllowlist } : {}),
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
// F-04: materialize runs in the execution worktree, never in the
|
|
918
|
+
// release workspace. Private workspace-side inputs reach the hook
|
|
919
|
+
// exclusively through the frozen execution bundle installed above —
|
|
920
|
+
// no live workspace root is announced anymore (F-01 / T1).
|
|
921
|
+
root: worktreePath,
|
|
922
|
+
env: process.env,
|
|
923
|
+
},
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
if (hookResult.exitCode !== 0) {
|
|
711
927
|
await evidence.append({
|
|
712
928
|
phase: 'materialize',
|
|
713
929
|
status: 'failed',
|
|
714
|
-
|
|
715
|
-
stdoutTail:
|
|
930
|
+
exitCode: hookResult.exitCode,
|
|
931
|
+
stdoutTail: boundedOutputTail(hookResult.stdout),
|
|
932
|
+
stderrTail: boundedOutputTail(hookResult.stderr),
|
|
716
933
|
});
|
|
717
934
|
await failBlocked(new ReleaseError(
|
|
718
935
|
POST_PUBLISH_VERIFY_FAILED,
|
|
719
|
-
|
|
720
|
-
{ stdoutTail:
|
|
936
|
+
`materialize hook exited with code ${hookResult.exitCode}; payload cannot be trusted`,
|
|
937
|
+
{ exitCode: hookResult.exitCode, stdoutTail: boundedOutputTail(hookResult.stdout), stderrTail: boundedOutputTail(hookResult.stderr) },
|
|
721
938
|
));
|
|
722
939
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
940
|
+
|
|
941
|
+
// requireReport: stdout-first-json deep compare (declared equals subset).
|
|
942
|
+
if (materialize.requireReport) {
|
|
943
|
+
const report = parseFirstJsonObject(hookResult.stdout);
|
|
944
|
+
if (!report) {
|
|
726
945
|
await evidence.append({
|
|
727
946
|
phase: 'materialize',
|
|
728
947
|
status: 'failed',
|
|
729
|
-
reason: 'report-
|
|
730
|
-
|
|
731
|
-
(k) => JSON.stringify(report[k]) !== JSON.stringify(equals[k]),
|
|
732
|
-
),
|
|
948
|
+
reason: 'report-missing',
|
|
949
|
+
stdoutTail: boundedOutputTail(hookResult.stdout),
|
|
733
950
|
});
|
|
734
951
|
await failBlocked(new ReleaseError(
|
|
735
952
|
POST_PUBLISH_VERIFY_FAILED,
|
|
736
|
-
|
|
737
|
-
{
|
|
953
|
+
'materialize report missing: no JSON object found on stdout (requireReport.parse = stdout-first-json)',
|
|
954
|
+
{ stdoutTail: boundedOutputTail(hookResult.stdout) },
|
|
738
955
|
));
|
|
739
956
|
}
|
|
957
|
+
const equals = materialize.requireReport.equals ?? {};
|
|
958
|
+
for (const [key, expectedValue] of Object.entries(equals)) {
|
|
959
|
+
if (JSON.stringify(report[key]) !== JSON.stringify(expectedValue)) {
|
|
960
|
+
await evidence.append({
|
|
961
|
+
phase: 'materialize',
|
|
962
|
+
status: 'failed',
|
|
963
|
+
reason: 'report-mismatch',
|
|
964
|
+
mismatchedKeys: Object.keys(equals).filter(
|
|
965
|
+
(k) => JSON.stringify(report[k]) !== JSON.stringify(equals[k]),
|
|
966
|
+
),
|
|
967
|
+
});
|
|
968
|
+
await failBlocked(new ReleaseError(
|
|
969
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
970
|
+
`materialize report mismatch: "${key}" did not meet the frozen requireReport contract`,
|
|
971
|
+
{ mismatchedKey: key },
|
|
972
|
+
));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
740
975
|
}
|
|
741
|
-
}
|
|
742
976
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
977
|
+
// outputMarker: the LAST line containing the marker announces the payload.
|
|
978
|
+
const announced = parseOutputMarker(hookResult.stdout, materialize.outputMarker);
|
|
979
|
+
if (!announced) {
|
|
980
|
+
await evidence.append({
|
|
981
|
+
phase: 'materialize',
|
|
982
|
+
status: 'failed',
|
|
983
|
+
reason: 'marker-missing',
|
|
984
|
+
stdoutTail: boundedOutputTail(hookResult.stdout),
|
|
985
|
+
});
|
|
986
|
+
await failBlocked(new ReleaseError(
|
|
987
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
988
|
+
`materialize output marker "${materialize.outputMarker}" not found on stdout; payload directory unbound`,
|
|
989
|
+
{ stdoutTail: boundedOutputTail(hookResult.stdout) },
|
|
990
|
+
));
|
|
991
|
+
}
|
|
992
|
+
payloadReal = await assertContainedDirectory(
|
|
993
|
+
worktreePath,
|
|
994
|
+
resolve(worktreePath, announced),
|
|
995
|
+
'materialized payload directory',
|
|
996
|
+
);
|
|
997
|
+
await evidence.append({ phase: 'materialize', status: 'passed', payloadDirAnnounced: announced });
|
|
998
|
+
} else {
|
|
999
|
+
await evidence.append({ phase: 'materialize', status: 'started', mechanism: PROJECTION_MECHANISM });
|
|
1000
|
+
|
|
1001
|
+
// Release-domain parameter selection ONLY: the frozen mapping rides the
|
|
1002
|
+
// plan digest; live project configuration is never read here.
|
|
1003
|
+
const publicFiles = postPublish.executionBundle?.publicFiles;
|
|
1004
|
+
if (!Array.isArray(publicFiles) || publicFiles.length === 0) {
|
|
1005
|
+
await evidence.append({
|
|
1006
|
+
phase: 'materialize',
|
|
1007
|
+
status: 'failed',
|
|
1008
|
+
mechanism: PROJECTION_MECHANISM,
|
|
1009
|
+
reason: 'public-files-missing',
|
|
1010
|
+
});
|
|
1011
|
+
await failBlocked(new ReleaseError(
|
|
1012
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
1013
|
+
'public payload projection: the frozen plan declares no materialize hook and carries no non-empty executionBundle.publicFiles mapping; the payload cannot be staged',
|
|
1014
|
+
));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Disposable candidate staging root, external to the payload root and
|
|
1018
|
+
// owned by the saga (cleaned up with tmpBase).
|
|
1019
|
+
const candidateRoot = join(tmpBase, 'projection-candidate');
|
|
1020
|
+
try {
|
|
1021
|
+
await mkdir(candidateRoot, { recursive: true });
|
|
1022
|
+
const projected = await projectPublicPayload({
|
|
1023
|
+
executionWorktreeRoot: worktreePath,
|
|
1024
|
+
candidateRoot,
|
|
1025
|
+
publicFiles,
|
|
1026
|
+
});
|
|
1027
|
+
payloadReal = projected.payloadRoot;
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
await evidence.append({
|
|
1030
|
+
phase: 'materialize',
|
|
1031
|
+
status: 'failed',
|
|
1032
|
+
mechanism: PROJECTION_MECHANISM,
|
|
1033
|
+
error: boundedOutputTail(err?.message ?? String(err)),
|
|
1034
|
+
});
|
|
1035
|
+
await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
|
|
1036
|
+
POST_PUBLISH_VERIFY_FAILED,
|
|
1037
|
+
`public payload projection failed: ${err?.message ?? err}`,
|
|
1038
|
+
));
|
|
1039
|
+
}
|
|
746
1040
|
await evidence.append({
|
|
747
1041
|
phase: 'materialize',
|
|
748
|
-
status: '
|
|
749
|
-
|
|
750
|
-
|
|
1042
|
+
status: 'passed',
|
|
1043
|
+
mechanism: PROJECTION_MECHANISM,
|
|
1044
|
+
payloadDir: PUBLIC_PAYLOAD_DIRNAME,
|
|
1045
|
+
fileCount: publicFiles.length,
|
|
751
1046
|
});
|
|
752
|
-
await failBlocked(new ReleaseError(
|
|
753
|
-
POST_PUBLISH_VERIFY_FAILED,
|
|
754
|
-
`materialize output marker "${materialize.outputMarker}" not found on stdout; payload directory unbound`,
|
|
755
|
-
{ stdoutTail: tail(hookResult.stdout) },
|
|
756
|
-
));
|
|
757
1047
|
}
|
|
758
|
-
const payloadReal = await assertContainedDirectory(
|
|
759
|
-
worktreePath,
|
|
760
|
-
resolve(worktreePath, announced),
|
|
761
|
-
'materialized payload directory',
|
|
762
|
-
);
|
|
763
|
-
await evidence.append({ phase: 'materialize', status: 'passed', payloadDirAnnounced: announced });
|
|
764
1048
|
|
|
765
1049
|
// =======================================================================
|
|
766
1050
|
// Declared postPublish steps, in order (fail-closed).
|
|
767
1051
|
// =======================================================================
|
|
768
1052
|
for (const step of postPublish.steps ?? []) {
|
|
1053
|
+
if (dryRun === true) {
|
|
1054
|
+
// R1 dry-run contract: steps are arbitrary project code with
|
|
1055
|
+
// potentially remote side effects; they never execute in a rehearsal.
|
|
1056
|
+
await evidence.append({ phase: 'postpublish-step', step: step.name, status: 'skipped', reason: 'DRY_RUN' });
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
if (pendingHookApprovals.length > 0) {
|
|
1060
|
+
// A requiresApproval hook is still unapproved: the step pipeline must
|
|
1061
|
+
// not execute before the checkpoint approval exists; the approved
|
|
1062
|
+
// reconcile rerun re-executes the steps (idempotence-by-default).
|
|
1063
|
+
await evidence.append({
|
|
1064
|
+
phase: 'postpublish-step',
|
|
1065
|
+
step: step.name,
|
|
1066
|
+
status: 'skipped',
|
|
1067
|
+
reason: 'AWAITING_CHECKPOINT_APPROVAL',
|
|
1068
|
+
pendingHookApprovals: pendingHookApprovals.map((hook) => hook.id),
|
|
1069
|
+
});
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
769
1072
|
await evidence.append({ phase: 'postpublish-step', step: step.name, status: 'started' });
|
|
770
1073
|
const stepResult = await hookRunner(
|
|
771
1074
|
{
|
|
@@ -774,7 +1077,12 @@ export async function distributeRelease(options) {
|
|
|
774
1077
|
...(step.timeoutMs !== undefined ? { timeoutMs: step.timeoutMs } : {}),
|
|
775
1078
|
...(step.envAllowlist ? { envAllowlist: step.envAllowlist } : {}),
|
|
776
1079
|
},
|
|
777
|
-
{
|
|
1080
|
+
{
|
|
1081
|
+
// F-04: steps run in the execution worktree (see materialize).
|
|
1082
|
+
// Private inputs arrive via the frozen execution bundle only.
|
|
1083
|
+
root: worktreePath,
|
|
1084
|
+
env: process.env,
|
|
1085
|
+
},
|
|
778
1086
|
);
|
|
779
1087
|
if (stepResult.exitCode !== 0) {
|
|
780
1088
|
await evidence.append({
|
|
@@ -782,8 +1090,8 @@ export async function distributeRelease(options) {
|
|
|
782
1090
|
step: step.name,
|
|
783
1091
|
status: 'failed',
|
|
784
1092
|
exitCode: stepResult.exitCode,
|
|
785
|
-
stdoutTail:
|
|
786
|
-
stderrTail:
|
|
1093
|
+
stdoutTail: boundedOutputTail(stepResult.stdout),
|
|
1094
|
+
stderrTail: boundedOutputTail(stepResult.stderr),
|
|
787
1095
|
});
|
|
788
1096
|
await failBlocked(new ReleaseError(
|
|
789
1097
|
GATE_FAILED,
|
|
@@ -1022,15 +1330,267 @@ export async function distributeRelease(options) {
|
|
|
1022
1330
|
}
|
|
1023
1331
|
|
|
1024
1332
|
// =======================================================================
|
|
1025
|
-
//
|
|
1333
|
+
// postPublish hooks (distribute phase), executed AFTER the target writes.
|
|
1334
|
+
// Contract (design §2.3): the read-only frozen-plan projection travels via
|
|
1335
|
+
// RELEASE_SKILL_POSTPUBLISH_CONTEXT; hooks run inside the frozen tag
|
|
1336
|
+
// worktree; a failure stops the hook chain; a requiresApproval hook
|
|
1337
|
+
// without a checkpoint approval parks at AWAITING_APPROVAL and never
|
|
1338
|
+
// executes; dry-run executes nothing; postVerify-phase hooks belong to
|
|
1339
|
+
// the independent postVerify run (R3) and are evidenced as deferred.
|
|
1340
|
+
// =======================================================================
|
|
1341
|
+
if (deferredPostVerifyHooks > 0) {
|
|
1342
|
+
await evidence.append({ phase: 'postpublish-hooks', deferredPostVerifyHooks });
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
let hooksStopped = stopped;
|
|
1346
|
+
let awaitingApproval = 0;
|
|
1347
|
+
let hookSuccesses = 0;
|
|
1348
|
+
const hookContextProjection = buildPostPublishContext({
|
|
1349
|
+
plan,
|
|
1350
|
+
runId,
|
|
1351
|
+
sourceRun,
|
|
1352
|
+
payloadDir: payloadReal,
|
|
1353
|
+
phase: 'distribute',
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
// Proposal documents must stay byte-deterministic across redeliveries of
|
|
1357
|
+
// the SAME release event (NO_CHANGE idempotence on reconcile reruns):
|
|
1358
|
+
// they travel with the stable lineage-derived event identity, not the
|
|
1359
|
+
// per-attempt runId.
|
|
1360
|
+
const proposalContextProjection = {
|
|
1361
|
+
...hookContextProjection,
|
|
1362
|
+
runId: `distribute-${sourceRunId}`,
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
for (const hook of distributeHooks) {
|
|
1366
|
+
const cp = checkpointById.get(hook.id);
|
|
1367
|
+
cp.startedAt = clockFn();
|
|
1368
|
+
|
|
1369
|
+
if (hooksStopped) {
|
|
1370
|
+
cp.status = 'SKIPPED';
|
|
1371
|
+
cp.reason = 'EARLIER_TARGET_FAILED';
|
|
1372
|
+
cp.finishedAt = clockFn();
|
|
1373
|
+
await evidence.append({
|
|
1374
|
+
phase: 'postpublish-hook',
|
|
1375
|
+
hookId: hook.id,
|
|
1376
|
+
status: 'skipped',
|
|
1377
|
+
reason: 'EARLIER_TARGET_FAILED',
|
|
1378
|
+
});
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
if (dryRun === true) {
|
|
1383
|
+
cp.status = 'SKIPPED';
|
|
1384
|
+
cp.reason = 'DRY_RUN';
|
|
1385
|
+
cp.finishedAt = clockFn();
|
|
1386
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'skipped', reason: 'DRY_RUN' });
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
if (effectiveHookRequiresApproval(hook) && !approvedHookIds.has(hook.id)) {
|
|
1391
|
+
// No checkpoint approval: the hook must not execute. It parks (does
|
|
1392
|
+
// not stop the chain — later reconcile reruns retry it once approved).
|
|
1393
|
+
cp.status = 'AWAITING_APPROVAL';
|
|
1394
|
+
cp.finishedAt = clockFn();
|
|
1395
|
+
awaitingApproval += 1;
|
|
1396
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'awaiting-approval' });
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// -----------------------------------------------------------------
|
|
1401
|
+
// Preset hooks dispatch through the R4 preset executor (one seam for
|
|
1402
|
+
// every registered preset; fail-closed wording for presets registered
|
|
1403
|
+
// but not yet shipped).
|
|
1404
|
+
// -----------------------------------------------------------------
|
|
1405
|
+
if (hook.preset !== undefined) {
|
|
1406
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
|
|
1407
|
+
let delivery;
|
|
1408
|
+
try {
|
|
1409
|
+
delivery = await executePresetHook({
|
|
1410
|
+
hook,
|
|
1411
|
+
contextProjection: hookContextProjection,
|
|
1412
|
+
proposalContextProjection,
|
|
1413
|
+
commitIdentity: postPublish.commitIdentity,
|
|
1414
|
+
// F-04: presets receive the RELEASE workspace root (target.workspace
|
|
1415
|
+
// resolution + release-workspace write exclusion). The detached
|
|
1416
|
+
// worktree is the execution worktree and never impersonates it.
|
|
1417
|
+
releaseWorkspaceRoot,
|
|
1418
|
+
evidencePath: join(runDir, 'evidence.jsonl'),
|
|
1419
|
+
payloadDir: hookContextProjection.payloadDir,
|
|
1420
|
+
exec,
|
|
1421
|
+
hookRunner,
|
|
1422
|
+
});
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
const code = mapToSchemaCode(err?.code);
|
|
1425
|
+
cp.status = 'FAILED';
|
|
1426
|
+
cp.error = { code, message: err?.message ?? String(err) };
|
|
1427
|
+
cp.finishedAt = clockFn();
|
|
1428
|
+
failures += 1;
|
|
1429
|
+
hooksStopped = true;
|
|
1430
|
+
await evidence.append({
|
|
1431
|
+
phase: 'postpublish-hook',
|
|
1432
|
+
hookId: hook.id,
|
|
1433
|
+
status: 'failed',
|
|
1434
|
+
error: err?.message ?? String(err),
|
|
1435
|
+
details: { code },
|
|
1436
|
+
});
|
|
1437
|
+
await snapshot(PARTIAL);
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
if (delivery.status === 'NO_CHANGE') {
|
|
1442
|
+
cp.status = 'NO_CHANGE';
|
|
1443
|
+
cp.mode = 'no-change';
|
|
1444
|
+
cp.finishedAt = clockFn();
|
|
1445
|
+
hookSuccesses += 1;
|
|
1446
|
+
await evidence.append({
|
|
1447
|
+
phase: 'postpublish-hook',
|
|
1448
|
+
hookId: hook.id,
|
|
1449
|
+
status: 'no-change',
|
|
1450
|
+
...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
|
|
1451
|
+
// §2.6 execution realpath evidence (R4 review m-2).
|
|
1452
|
+
...(delivery.observation?.workspaceRealpath
|
|
1453
|
+
? { workspaceRealpath: delivery.observation.workspaceRealpath }
|
|
1454
|
+
: {}),
|
|
1455
|
+
...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
|
|
1456
|
+
});
|
|
1457
|
+
await snapshot(PARTIAL);
|
|
1458
|
+
continue;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
cp.status = 'SUCCEEDED';
|
|
1462
|
+
if (delivery.observation?.mode === 'pushed' && delivery.observation?.pushedCommit) {
|
|
1463
|
+
cp.mode = 'pushed';
|
|
1464
|
+
cp.pushedCommit = delivery.observation.pushedCommit;
|
|
1465
|
+
}
|
|
1466
|
+
cp.finishedAt = clockFn();
|
|
1467
|
+
hookSuccesses += 1;
|
|
1468
|
+
await evidence.append({
|
|
1469
|
+
phase: 'postpublish-hook',
|
|
1470
|
+
hookId: hook.id,
|
|
1471
|
+
status: 'succeeded',
|
|
1472
|
+
preset: hook.preset,
|
|
1473
|
+
mode: delivery.mode ?? delivery.observation?.mode,
|
|
1474
|
+
...(delivery.observation?.pushedCommit ? { pushedCommit: delivery.observation.pushedCommit } : {}),
|
|
1475
|
+
...(delivery.manualSyncPrompt ? { manualSyncPrompt: delivery.manualSyncPrompt } : {}),
|
|
1476
|
+
...(delivery.checklist ? { checklist: delivery.checklist } : {}),
|
|
1477
|
+
...(delivery.degradedToNotifyHandoff === true ? { degradedToNotifyHandoff: true } : {}),
|
|
1478
|
+
...(Array.isArray(delivery.observations) ? { targets: delivery.observations } : {}),
|
|
1479
|
+
// §2.6 execution realpath evidence (R4 review m-2) + explicit
|
|
1480
|
+
// cross-check skip note (R4 review m-4).
|
|
1481
|
+
...(delivery.observation?.workspaceRealpath
|
|
1482
|
+
? { workspaceRealpath: delivery.observation.workspaceRealpath }
|
|
1483
|
+
: {}),
|
|
1484
|
+
...(delivery.workspaceRealpath ? { workspaceRealpath: delivery.workspaceRealpath } : {}),
|
|
1485
|
+
...(delivery.observation?.crossCheck ? { crossCheck: delivery.observation.crossCheck } : {}),
|
|
1486
|
+
});
|
|
1487
|
+
await snapshot(PARTIAL);
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (!Array.isArray(hook.command)) {
|
|
1492
|
+
// Fail-closed: a hook with no executable command stops the chain.
|
|
1493
|
+
cp.status = 'FAILED';
|
|
1494
|
+
cp.error = {
|
|
1495
|
+
code: POST_PUBLISH_VERIFY_FAILED,
|
|
1496
|
+
message: `hook "${hook.id}" has no executable command`,
|
|
1497
|
+
};
|
|
1498
|
+
cp.finishedAt = clockFn();
|
|
1499
|
+
failures += 1;
|
|
1500
|
+
hooksStopped = true;
|
|
1501
|
+
await evidence.append({
|
|
1502
|
+
phase: 'postpublish-hook',
|
|
1503
|
+
hookId: hook.id,
|
|
1504
|
+
status: 'failed',
|
|
1505
|
+
reason: 'no-command',
|
|
1506
|
+
});
|
|
1507
|
+
await snapshot(PARTIAL);
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'started' });
|
|
1512
|
+
let hookExecution;
|
|
1513
|
+
try {
|
|
1514
|
+
hookExecution = await hookRunner(
|
|
1515
|
+
{
|
|
1516
|
+
command: hook.command,
|
|
1517
|
+
...(hook.cwd ? { cwd: hook.cwd } : {}),
|
|
1518
|
+
...(hook.timeoutMs !== undefined ? { timeoutMs: hook.timeoutMs } : {}),
|
|
1519
|
+
...(hook.envAllowlist ? { envAllowlist: hook.envAllowlist } : {}),
|
|
1520
|
+
},
|
|
1521
|
+
{
|
|
1522
|
+
// F-04: custom command hooks keep running in the execution
|
|
1523
|
+
// worktree; the runner's cwd containment binds them there.
|
|
1524
|
+
root: worktreePath,
|
|
1525
|
+
env: process.env,
|
|
1526
|
+
injectEnv: {
|
|
1527
|
+
[POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(hookContextProjection),
|
|
1528
|
+
},
|
|
1529
|
+
},
|
|
1530
|
+
);
|
|
1531
|
+
} catch (err) {
|
|
1532
|
+
// HOOK_TIMEOUT (or a runner defect): FAILED checkpoint, stop the chain.
|
|
1533
|
+
const code = err?.code === 'HOOK_TIMEOUT' ? 'HOOK_TIMEOUT' : POST_PUBLISH_VERIFY_FAILED;
|
|
1534
|
+
cp.status = 'FAILED';
|
|
1535
|
+
cp.error = { code, message: err?.message ?? String(err) };
|
|
1536
|
+
cp.finishedAt = clockFn();
|
|
1537
|
+
failures += 1;
|
|
1538
|
+
hooksStopped = true;
|
|
1539
|
+
await evidence.append({
|
|
1540
|
+
phase: 'postpublish-hook',
|
|
1541
|
+
hookId: hook.id,
|
|
1542
|
+
status: 'failed',
|
|
1543
|
+
error: err?.message ?? String(err),
|
|
1544
|
+
});
|
|
1545
|
+
await snapshot(PARTIAL);
|
|
1546
|
+
continue;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
if (hookExecution.exitCode !== 0) {
|
|
1550
|
+
cp.status = 'FAILED';
|
|
1551
|
+
cp.error = {
|
|
1552
|
+
code: POST_PUBLISH_VERIFY_FAILED,
|
|
1553
|
+
message: `postPublish hook "${hook.id}" exited with code ${hookExecution.exitCode}`,
|
|
1554
|
+
};
|
|
1555
|
+
cp.finishedAt = clockFn();
|
|
1556
|
+
failures += 1;
|
|
1557
|
+
hooksStopped = true;
|
|
1558
|
+
await evidence.append({
|
|
1559
|
+
phase: 'postpublish-hook',
|
|
1560
|
+
hookId: hook.id,
|
|
1561
|
+
status: 'failed',
|
|
1562
|
+
exitCode: hookExecution.exitCode,
|
|
1563
|
+
stdoutTail: boundedOutputTail(hookExecution.stdout),
|
|
1564
|
+
stderrTail: boundedOutputTail(hookExecution.stderr),
|
|
1565
|
+
});
|
|
1566
|
+
await snapshot(PARTIAL);
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
cp.status = 'SUCCEEDED';
|
|
1571
|
+
cp.finishedAt = clockFn();
|
|
1572
|
+
hookSuccesses += 1;
|
|
1573
|
+
await evidence.append({ phase: 'postpublish-hook', hookId: hook.id, status: 'succeeded' });
|
|
1574
|
+
await snapshot(PARTIAL);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// =======================================================================
|
|
1578
|
+
// Classification (returned, not thrown):
|
|
1579
|
+
// - DISTRIBUTED: no failures and no awaiting-approval hooks;
|
|
1580
|
+
// - NEEDS_INPUT: only awaiting-approval checkpoints and zero external
|
|
1581
|
+
// side effects so far (pure input-needed state, never PARTIAL);
|
|
1582
|
+
// - PARTIAL: at least one external success (pushed write or succeeded
|
|
1583
|
+
// hook) alongside failures or awaiting-approval checkpoints;
|
|
1584
|
+
// - BLOCKED: failures with zero external side effects landed.
|
|
1026
1585
|
// =======================================================================
|
|
1586
|
+
const externalCheckpointSuccesses = pushedWrites + hookSuccesses;
|
|
1027
1587
|
let overallStatus;
|
|
1028
|
-
if (failures === 0) {
|
|
1588
|
+
if (failures === 0 && awaitingApproval === 0) {
|
|
1029
1589
|
overallStatus = DISTRIBUTED;
|
|
1030
|
-
} else if (
|
|
1031
|
-
overallStatus = PARTIAL;
|
|
1590
|
+
} else if (failures === 0) {
|
|
1591
|
+
overallStatus = externalCheckpointSuccesses > 0 ? PARTIAL : NEEDS_INPUT;
|
|
1032
1592
|
} else {
|
|
1033
|
-
overallStatus = BLOCKED;
|
|
1593
|
+
overallStatus = externalCheckpointSuccesses > 0 ? PARTIAL : BLOCKED;
|
|
1034
1594
|
}
|
|
1035
1595
|
|
|
1036
1596
|
const finishedAt = clockFn();
|