release-skill 0.6.3 → 0.7.1
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 +36 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +17 -23
- package/README.zh-CN.md +8 -20
- 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 +34866 -31254
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +6 -6
- package/adapters/claude/schemas/release-plan.schema.json +136 -4
- package/adapters/claude/schemas/release-project.schema.json +13 -4
- 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 +34866 -31254
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +6 -6
- package/adapters/codex/schemas/release-plan.schema.json +136 -4
- package/adapters/codex/schemas/release-project.schema.json +13 -4
- 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 +34866 -31254
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/.render-manifest.json +6 -6
- package/adapters/kimi/schemas/release-plan.schema.json +136 -4
- package/adapters/kimi/schemas/release-project.schema.json +13 -4
- 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 +34866 -31254
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
- package/adapters/workbuddy/schemas/release-plan.schema.json +136 -4
- package/adapters/workbuddy/schemas/release-project.schema.json +13 -4
- 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 +6 -6
- package/bin/release-skill.bundle.mjs +34866 -31254
- package/bin/rules.json +1 -1
- package/package.json +5 -4
- package/platform-manifest.json +2 -2
- package/references/02-project-config.md +1 -1
- package/references/06-adapter-contract.md +6 -3
- package/schemas/.render-manifest.json +6 -6
- package/schemas/release-plan.schema.json +136 -4
- package/schemas/release-project.schema.json +13 -4
- package/src/adapters/distribute-git.mjs +17 -11
- package/src/commands/approve.mjs +6 -10
- package/src/commands/distribute.mjs +251 -69
- package/src/commands/postverify.mjs +81 -7
- package/src/commands/prepare.mjs +176 -3
- package/src/core/docs-refresh-preset.mjs +8 -4
- package/src/core/evidence.mjs +8 -1
- package/src/core/git-url-policy.mjs +214 -0
- package/src/core/marketplace-registry-entry.mjs +4 -3
- package/src/core/postpublish-approval.mjs +132 -1
- package/src/core/postpublish-bundle.mjs +342 -0
- package/src/core/postpublish-projection.mjs +210 -0
- package/src/core/postpublish.mjs +26 -19
- package/src/core/preset-executor.mjs +25 -6
- package/src/core/preset-gitwrite.mjs +37 -16
- package/src/core/presets.mjs +67 -19
- package/src/core/proposal-inbox.mjs +36 -20
- package/src/core/redact.mjs +16 -3
- package/src/producers/build-adapters.mjs +28 -14
- package/src/snapshot/scan.mjs +6 -2
|
@@ -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
|
|
@@ -72,7 +96,9 @@ import {
|
|
|
72
96
|
POSTPUBLISH_CONTEXT_ENV,
|
|
73
97
|
PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
74
98
|
} from '../core/postpublish.mjs';
|
|
75
|
-
import {
|
|
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';
|
|
76
102
|
import { executePresetHook } from '../core/preset-executor.mjs';
|
|
77
103
|
import {
|
|
78
104
|
ReleaseError,
|
|
@@ -233,7 +259,9 @@ function resolvePluginName(plan, unitId) {
|
|
|
233
259
|
* @param {Function} [options.runHookFn] - Injectable hook runner (tests).
|
|
234
260
|
* @param {string[]} [options.postpublishApprovalPaths] - Checkpoint approval
|
|
235
261
|
* records for requiresApproval postPublish hooks (v0.6.3 R1); each record
|
|
236
|
-
* binds (planDigest, hookId)
|
|
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.
|
|
237
265
|
*
|
|
238
266
|
* @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
|
|
239
267
|
*
|
|
@@ -503,9 +531,14 @@ export async function distributeRelease(options) {
|
|
|
503
531
|
|
|
504
532
|
// =======================================================================
|
|
505
533
|
// Gate: checkpoint approvals for requiresApproval hooks. Every provided
|
|
506
|
-
// record is validated fail-closed BEFORE any write
|
|
507
|
-
//
|
|
508
|
-
//
|
|
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.
|
|
509
542
|
// =======================================================================
|
|
510
543
|
const approvedHookIds = new Set();
|
|
511
544
|
const hookApprovalPaths = postpublishApprovalPaths ?? [];
|
|
@@ -537,6 +570,18 @@ export async function distributeRelease(options) {
|
|
|
537
570
|
{ hookApprovalPath },
|
|
538
571
|
));
|
|
539
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
|
+
}
|
|
540
585
|
validatePostPublishApproval(plan, hookApproval, { clock: clockFn });
|
|
541
586
|
if (approvedHookIds.has(hookApproval.hookId)) {
|
|
542
587
|
await failBlocked(new ReleaseError(
|
|
@@ -787,94 +832,219 @@ export async function distributeRelease(options) {
|
|
|
787
832
|
await evidence.append({ phase: 'worktree', status: 'passed' });
|
|
788
833
|
|
|
789
834
|
// =======================================================================
|
|
790
|
-
//
|
|
791
|
-
//
|
|
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.
|
|
792
842
|
// =======================================================================
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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) {
|
|
807
871
|
await evidence.append({
|
|
808
|
-
phase: '
|
|
872
|
+
phase: 'worktree',
|
|
873
|
+
gate: 'execution-bundle',
|
|
809
874
|
status: 'failed',
|
|
810
|
-
|
|
811
|
-
stdoutTail: boundedOutputTail(hookResult.stdout),
|
|
812
|
-
stderrTail: boundedOutputTail(hookResult.stderr),
|
|
875
|
+
error: boundedOutputTail(err?.message ?? String(err)),
|
|
813
876
|
});
|
|
814
|
-
await failBlocked(new ReleaseError(
|
|
815
|
-
|
|
816
|
-
`
|
|
817
|
-
{ exitCode: hookResult.exitCode, stdoutTail: boundedOutputTail(hookResult.stdout), stderrTail: boundedOutputTail(hookResult.stderr) },
|
|
877
|
+
await failBlocked(err instanceof ReleaseError ? err : new ReleaseError(
|
|
878
|
+
GATE_FAILED,
|
|
879
|
+
`cannot verify the frozen execution bundle: ${err?.message ?? err}`,
|
|
818
880
|
));
|
|
819
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;
|
|
820
905
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
906
|
+
if (materialize !== undefined) {
|
|
907
|
+
await evidence.append({ phase: 'materialize', status: 'started' });
|
|
908
|
+
|
|
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) {
|
|
825
927
|
await evidence.append({
|
|
826
928
|
phase: 'materialize',
|
|
827
929
|
status: 'failed',
|
|
828
|
-
|
|
930
|
+
exitCode: hookResult.exitCode,
|
|
829
931
|
stdoutTail: boundedOutputTail(hookResult.stdout),
|
|
932
|
+
stderrTail: boundedOutputTail(hookResult.stderr),
|
|
830
933
|
});
|
|
831
934
|
await failBlocked(new ReleaseError(
|
|
832
935
|
POST_PUBLISH_VERIFY_FAILED,
|
|
833
|
-
|
|
834
|
-
{ stdoutTail: boundedOutputTail(hookResult.stdout) },
|
|
936
|
+
`materialize hook exited with code ${hookResult.exitCode}; payload cannot be trusted`,
|
|
937
|
+
{ exitCode: hookResult.exitCode, stdoutTail: boundedOutputTail(hookResult.stdout), stderrTail: boundedOutputTail(hookResult.stderr) },
|
|
835
938
|
));
|
|
836
939
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
940
|
+
|
|
941
|
+
// requireReport: stdout-first-json deep compare (declared equals subset).
|
|
942
|
+
if (materialize.requireReport) {
|
|
943
|
+
const report = parseFirstJsonObject(hookResult.stdout);
|
|
944
|
+
if (!report) {
|
|
840
945
|
await evidence.append({
|
|
841
946
|
phase: 'materialize',
|
|
842
947
|
status: 'failed',
|
|
843
|
-
reason: 'report-
|
|
844
|
-
|
|
845
|
-
(k) => JSON.stringify(report[k]) !== JSON.stringify(equals[k]),
|
|
846
|
-
),
|
|
948
|
+
reason: 'report-missing',
|
|
949
|
+
stdoutTail: boundedOutputTail(hookResult.stdout),
|
|
847
950
|
});
|
|
848
951
|
await failBlocked(new ReleaseError(
|
|
849
952
|
POST_PUBLISH_VERIFY_FAILED,
|
|
850
|
-
|
|
851
|
-
{
|
|
953
|
+
'materialize report missing: no JSON object found on stdout (requireReport.parse = stdout-first-json)',
|
|
954
|
+
{ stdoutTail: boundedOutputTail(hookResult.stdout) },
|
|
852
955
|
));
|
|
853
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
|
+
}
|
|
854
975
|
}
|
|
855
|
-
}
|
|
856
976
|
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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
|
+
}
|
|
860
1040
|
await evidence.append({
|
|
861
1041
|
phase: 'materialize',
|
|
862
|
-
status: '
|
|
863
|
-
|
|
864
|
-
|
|
1042
|
+
status: 'passed',
|
|
1043
|
+
mechanism: PROJECTION_MECHANISM,
|
|
1044
|
+
payloadDir: PUBLIC_PAYLOAD_DIRNAME,
|
|
1045
|
+
fileCount: publicFiles.length,
|
|
865
1046
|
});
|
|
866
|
-
await failBlocked(new ReleaseError(
|
|
867
|
-
POST_PUBLISH_VERIFY_FAILED,
|
|
868
|
-
`materialize output marker "${materialize.outputMarker}" not found on stdout; payload directory unbound`,
|
|
869
|
-
{ stdoutTail: boundedOutputTail(hookResult.stdout) },
|
|
870
|
-
));
|
|
871
1047
|
}
|
|
872
|
-
const payloadReal = await assertContainedDirectory(
|
|
873
|
-
worktreePath,
|
|
874
|
-
resolve(worktreePath, announced),
|
|
875
|
-
'materialized payload directory',
|
|
876
|
-
);
|
|
877
|
-
await evidence.append({ phase: 'materialize', status: 'passed', payloadDirAnnounced: announced });
|
|
878
1048
|
|
|
879
1049
|
// =======================================================================
|
|
880
1050
|
// Declared postPublish steps, in order (fail-closed).
|
|
@@ -907,7 +1077,12 @@ export async function distributeRelease(options) {
|
|
|
907
1077
|
...(step.timeoutMs !== undefined ? { timeoutMs: step.timeoutMs } : {}),
|
|
908
1078
|
...(step.envAllowlist ? { envAllowlist: step.envAllowlist } : {}),
|
|
909
1079
|
},
|
|
910
|
-
{
|
|
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
|
+
},
|
|
911
1086
|
);
|
|
912
1087
|
if (stepResult.exitCode !== 0) {
|
|
913
1088
|
await evidence.append({
|
|
@@ -1236,7 +1411,10 @@ export async function distributeRelease(options) {
|
|
|
1236
1411
|
contextProjection: hookContextProjection,
|
|
1237
1412
|
proposalContextProjection,
|
|
1238
1413
|
commitIdentity: postPublish.commitIdentity,
|
|
1239
|
-
|
|
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,
|
|
1240
1418
|
evidencePath: join(runDir, 'evidence.jsonl'),
|
|
1241
1419
|
payloadDir: hookContextProjection.payloadDir,
|
|
1242
1420
|
exec,
|
|
@@ -1341,9 +1519,13 @@ export async function distributeRelease(options) {
|
|
|
1341
1519
|
...(hook.envAllowlist ? { envAllowlist: hook.envAllowlist } : {}),
|
|
1342
1520
|
},
|
|
1343
1521
|
{
|
|
1522
|
+
// F-04: custom command hooks keep running in the execution
|
|
1523
|
+
// worktree; the runner's cwd containment binds them there.
|
|
1344
1524
|
root: worktreePath,
|
|
1345
1525
|
env: process.env,
|
|
1346
|
-
injectEnv: {
|
|
1526
|
+
injectEnv: {
|
|
1527
|
+
[POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(hookContextProjection),
|
|
1528
|
+
},
|
|
1347
1529
|
},
|
|
1348
1530
|
);
|
|
1349
1531
|
} catch (err) {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import { execFile as execFileCb } from 'node:child_process';
|
|
28
28
|
import { promisify } from 'node:util';
|
|
29
|
-
import { mkdir, readFile, rm } from 'node:fs/promises';
|
|
29
|
+
import { mkdir, readFile, realpath, rm } from 'node:fs/promises';
|
|
30
30
|
import { mkdtemp } from 'node:fs/promises';
|
|
31
31
|
import { tmpdir } from 'node:os';
|
|
32
32
|
import { join } from 'node:path';
|
|
@@ -56,7 +56,8 @@ import {
|
|
|
56
56
|
POSTPUBLISH_CONTEXT_ENV,
|
|
57
57
|
PAYLOAD_SOURCE_TAG_WORKTREE,
|
|
58
58
|
} from '../core/postpublish.mjs';
|
|
59
|
-
import {
|
|
59
|
+
import { verifyAndInstallExecutionBundle } from '../core/postpublish-bundle.mjs';
|
|
60
|
+
import { assertPostPublishApprovalAuthority, validatePostPublishApproval } from '../core/postpublish-approval.mjs';
|
|
60
61
|
import { executePresetHook } from '../core/preset-executor.mjs';
|
|
61
62
|
import {
|
|
62
63
|
ReleaseError,
|
|
@@ -122,7 +123,9 @@ function mapToSchemaCode(code) {
|
|
|
122
123
|
* @param {Function} [options.runHookFn] - Injectable hook runner (tests).
|
|
123
124
|
* @param {string[]} [options.postpublishApprovalPaths] - Checkpoint approval
|
|
124
125
|
* records for requiresApproval postVerify hooks; each record binds
|
|
125
|
-
* (planDigest, hookId)
|
|
126
|
+
* (planDigest, hookId), must be consumed from the immutable digest-
|
|
127
|
+
* addressed authority minted by approvePostPublishHook (F-02), and is
|
|
128
|
+
* validated fail-closed before any write.
|
|
126
129
|
*
|
|
127
130
|
* @returns {Promise<{ planPath: string, runPath: string, status: string, checkpoints: Object[] }>}
|
|
128
131
|
*
|
|
@@ -252,9 +255,14 @@ export async function postVerifyRelease(options) {
|
|
|
252
255
|
|
|
253
256
|
// =========================================================================
|
|
254
257
|
// Gate 4: checkpoint approvals for requiresApproval hooks. Every provided
|
|
255
|
-
// record is validated fail-closed BEFORE any write
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
+
// record is validated fail-closed BEFORE any write: first the immutable
|
|
259
|
+
// authority binding (F-02: the consumption path must BE the digest-
|
|
260
|
+
// addressed authority minted by approvePostPublishHook — recomputed
|
|
261
|
+
// planDigest directory, recomputed approvalDigest file name, strict
|
|
262
|
+
// no-follow regular-file read, no symlinked ancestor), then the content
|
|
263
|
+
// checks (schema, planDigest binding, declared hook, requiresApproval,
|
|
264
|
+
// 24h window, expiry). A bad approval aborts the whole run; a missing one
|
|
265
|
+
// parks the hook at AWAITING_APPROVAL.
|
|
258
266
|
// =========================================================================
|
|
259
267
|
const approvedHookIds = new Set();
|
|
260
268
|
const hookApprovalPaths = postpublishApprovalPaths ?? [];
|
|
@@ -279,6 +287,10 @@ export async function postVerifyRelease(options) {
|
|
|
279
287
|
{ hookApprovalPath },
|
|
280
288
|
);
|
|
281
289
|
}
|
|
290
|
+
// F-02: identical bytes anywhere else are not an approval. The authority
|
|
291
|
+
// assertion runs before content validation and before the hook may enter
|
|
292
|
+
// approvedHookIds.
|
|
293
|
+
await assertPostPublishApprovalAuthority(planPath, hookApprovalPath, plan, hookApprovalRaw);
|
|
282
294
|
validatePostPublishApproval(plan, hookApproval, { clock: clockFn });
|
|
283
295
|
if (approvedHookIds.has(hookApproval.hookId)) {
|
|
284
296
|
throw new ReleaseError(
|
|
@@ -428,6 +440,28 @@ export async function postVerifyRelease(options) {
|
|
|
428
440
|
// Durable pre-execute authority (seq 0).
|
|
429
441
|
await snapshot(DISTRIBUTING);
|
|
430
442
|
|
|
443
|
+
// =======================================================================
|
|
444
|
+
// F-04 root split: postVerify holds TWO distinct roots —
|
|
445
|
+
// - releaseWorkspaceRoot: the real project root the user releases from;
|
|
446
|
+
// only used to resolve preset target.workspace, compare the release-
|
|
447
|
+
// workspace write exclusion, and audit;
|
|
448
|
+
// - executionWorktreeRoot (worktreePath): the detached worktree at the
|
|
449
|
+
// frozen tagCommit; only used as the hook runner context.root for
|
|
450
|
+
// custom command hooks.
|
|
451
|
+
// The two roots never fall back onto each other through defaults.
|
|
452
|
+
// =======================================================================
|
|
453
|
+
let releaseWorkspaceRoot;
|
|
454
|
+
try {
|
|
455
|
+
releaseWorkspaceRoot = await realpath(root);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
await recordBlocked();
|
|
458
|
+
throw new ReleaseError(
|
|
459
|
+
GATE_FAILED,
|
|
460
|
+
`release workspace root does not resolve to an existing directory: ${err.message}`,
|
|
461
|
+
{ root, cause: err.code },
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
431
465
|
// R1 timing contract carries over: hooks run inside a detached worktree
|
|
432
466
|
// at the frozen tagCommit, never in the live workspace. Dry-run executes
|
|
433
467
|
// nothing, so no worktree is allocated for a rehearsal.
|
|
@@ -450,6 +484,41 @@ export async function postVerifyRelease(options) {
|
|
|
450
484
|
);
|
|
451
485
|
}
|
|
452
486
|
await evidence.append({ phase: 'worktree', status: 'passed' });
|
|
487
|
+
|
|
488
|
+
// =====================================================================
|
|
489
|
+
// Private execution bundle (F-01 / T1): postVerify re-entry consumes
|
|
490
|
+
// the SAME frozen bundle bytes — never the live workspace copies.
|
|
491
|
+
// Strictly re-read the digest-addressed bytes, recompute the closure
|
|
492
|
+
// through Foundation, and install ONLY the verified bytes into the
|
|
493
|
+
// fresh tag worktree before any hook runs; any mismatch fails closed
|
|
494
|
+
// before a hook or an external write.
|
|
495
|
+
// =====================================================================
|
|
496
|
+
let installedBundlePaths = [];
|
|
497
|
+
try {
|
|
498
|
+
({ installed: installedBundlePaths } = await verifyAndInstallExecutionBundle({
|
|
499
|
+
plan,
|
|
500
|
+
planPath,
|
|
501
|
+
worktreePath,
|
|
502
|
+
}));
|
|
503
|
+
} catch (err) {
|
|
504
|
+
await evidence.append({
|
|
505
|
+
phase: 'worktree',
|
|
506
|
+
gate: 'execution-bundle',
|
|
507
|
+
status: 'failed',
|
|
508
|
+
error: boundedOutputTail(err?.message ?? String(err)),
|
|
509
|
+
});
|
|
510
|
+
await recordBlocked();
|
|
511
|
+
throw err instanceof ReleaseError ? err : new ReleaseError(
|
|
512
|
+
GATE_FAILED,
|
|
513
|
+
`cannot verify the frozen execution bundle: ${err?.message ?? err}`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
await evidence.append({
|
|
517
|
+
phase: 'worktree',
|
|
518
|
+
gate: 'execution-bundle',
|
|
519
|
+
status: 'passed',
|
|
520
|
+
installed: installedBundlePaths,
|
|
521
|
+
});
|
|
453
522
|
}
|
|
454
523
|
|
|
455
524
|
// §2.3 context projection: verifyEvidence PRESENT (postVerify phase);
|
|
@@ -535,7 +604,10 @@ export async function postVerifyRelease(options) {
|
|
|
535
604
|
contextProjection: hookContextProjection,
|
|
536
605
|
proposalContextProjection,
|
|
537
606
|
commitIdentity: postPublish.commitIdentity,
|
|
538
|
-
|
|
607
|
+
// F-04: presets receive the RELEASE workspace root (target.workspace
|
|
608
|
+
// resolution + release-workspace write exclusion). The detached
|
|
609
|
+
// worktree is the execution worktree and never impersonates it.
|
|
610
|
+
releaseWorkspaceRoot,
|
|
539
611
|
evidencePath: join(runDir, 'evidence.jsonl'),
|
|
540
612
|
exec,
|
|
541
613
|
hookRunner,
|
|
@@ -623,6 +695,8 @@ export async function postVerifyRelease(options) {
|
|
|
623
695
|
...(hook.envAllowlist ? { envAllowlist: hook.envAllowlist } : {}),
|
|
624
696
|
},
|
|
625
697
|
{
|
|
698
|
+
// F-04: custom command hooks keep running in the execution
|
|
699
|
+
// worktree; the runner's cwd containment binds them there.
|
|
626
700
|
root: worktreePath,
|
|
627
701
|
env: process.env,
|
|
628
702
|
injectEnv: { [POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(hookContextProjection) },
|