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.
Files changed (90) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +36 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +17 -23
  10. package/README.zh-CN.md +8 -20
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/error-codes.json +1 -1
  14. package/adapters/claude/bin/kernel-protocol.json +1 -1
  15. package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
  16. package/adapters/claude/bin/license-texts/MIT.txt +21 -0
  17. package/adapters/claude/bin/registry.json +64 -1
  18. package/adapters/claude/bin/release-skill.bundle.mjs +34866 -31254
  19. package/adapters/claude/bin/rules.json +1 -1
  20. package/adapters/claude/schemas/.render-manifest.json +6 -6
  21. package/adapters/claude/schemas/release-plan.schema.json +136 -4
  22. package/adapters/claude/schemas/release-project.schema.json +13 -4
  23. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  24. package/adapters/codex/bin/error-codes.json +1 -1
  25. package/adapters/codex/bin/kernel-protocol.json +1 -1
  26. package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
  27. package/adapters/codex/bin/license-texts/MIT.txt +21 -0
  28. package/adapters/codex/bin/registry.json +64 -1
  29. package/adapters/codex/bin/release-skill.bundle.mjs +34866 -31254
  30. package/adapters/codex/bin/rules.json +1 -1
  31. package/adapters/codex/schemas/.render-manifest.json +6 -6
  32. package/adapters/codex/schemas/release-plan.schema.json +136 -4
  33. package/adapters/codex/schemas/release-project.schema.json +13 -4
  34. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  35. package/adapters/kimi/bin/error-codes.json +1 -1
  36. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  37. package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
  38. package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
  39. package/adapters/kimi/bin/registry.json +64 -1
  40. package/adapters/kimi/bin/release-skill.bundle.mjs +34866 -31254
  41. package/adapters/kimi/bin/rules.json +1 -1
  42. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  43. package/adapters/kimi/schemas/release-plan.schema.json +136 -4
  44. package/adapters/kimi/schemas/release-project.schema.json +13 -4
  45. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  46. package/adapters/workbuddy/bin/error-codes.json +1 -1
  47. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  48. package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
  49. package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
  50. package/adapters/workbuddy/bin/registry.json +64 -1
  51. package/adapters/workbuddy/bin/release-skill.bundle.mjs +34866 -31254
  52. package/adapters/workbuddy/bin/rules.json +1 -1
  53. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  54. package/adapters/workbuddy/schemas/release-plan.schema.json +136 -4
  55. package/adapters/workbuddy/schemas/release-project.schema.json +13 -4
  56. package/bin/error-codes.json +1 -1
  57. package/bin/kernel-protocol.json +1 -1
  58. package/bin/license-texts/Apache-2.0.txt +201 -0
  59. package/bin/license-texts/MIT.txt +21 -0
  60. package/bin/registry.json +64 -1
  61. package/bin/release-skill-cli.mjs +6 -6
  62. package/bin/release-skill.bundle.mjs +34866 -31254
  63. package/bin/rules.json +1 -1
  64. package/package.json +5 -4
  65. package/platform-manifest.json +2 -2
  66. package/references/02-project-config.md +1 -1
  67. package/references/06-adapter-contract.md +6 -3
  68. package/schemas/.render-manifest.json +6 -6
  69. package/schemas/release-plan.schema.json +136 -4
  70. package/schemas/release-project.schema.json +13 -4
  71. package/src/adapters/distribute-git.mjs +17 -11
  72. package/src/commands/approve.mjs +6 -10
  73. package/src/commands/distribute.mjs +251 -69
  74. package/src/commands/postverify.mjs +81 -7
  75. package/src/commands/prepare.mjs +176 -3
  76. package/src/core/docs-refresh-preset.mjs +8 -4
  77. package/src/core/evidence.mjs +8 -1
  78. package/src/core/git-url-policy.mjs +214 -0
  79. package/src/core/marketplace-registry-entry.mjs +4 -3
  80. package/src/core/postpublish-approval.mjs +132 -1
  81. package/src/core/postpublish-bundle.mjs +342 -0
  82. package/src/core/postpublish-projection.mjs +210 -0
  83. package/src/core/postpublish.mjs +26 -19
  84. package/src/core/preset-executor.mjs +25 -6
  85. package/src/core/preset-gitwrite.mjs +37 -16
  86. package/src/core/presets.mjs +67 -19
  87. package/src/core/proposal-inbox.mjs +36 -20
  88. package/src/core/redact.mjs +16 -3
  89. package/src/producers/build-adapters.mjs +28 -14
  90. 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. The consumer-declared
11
- * materialize hook runs inside that worktree and announces the isolated
12
- * payload directory through its `outputMarker` line.
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 { validatePostPublishApproval } from '../core/postpublish-approval.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';
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) and is validated fail-closed before any write.
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 (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.
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
- // Materialize: run the consumer hook inside the tag worktree, verify its
791
- // report, and bind the announced payload directory (fail-closed).
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
- await evidence.append({ phase: 'materialize', status: 'started' });
794
-
795
- const materialize = postPublish.materialize;
796
- const hookResult = await hookRunner(
797
- {
798
- command: materialize.command,
799
- ...(materialize.cwd ? { cwd: materialize.cwd } : {}),
800
- ...(materialize.timeoutMs !== undefined ? { timeoutMs: materialize.timeoutMs } : {}),
801
- ...(materialize.envAllowlist ? { envAllowlist: materialize.envAllowlist } : {}),
802
- },
803
- { root: worktreePath, env: process.env },
804
- );
805
-
806
- if (hookResult.exitCode !== 0) {
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: 'materialize',
872
+ phase: 'worktree',
873
+ gate: 'execution-bundle',
809
874
  status: 'failed',
810
- exitCode: hookResult.exitCode,
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
- POST_PUBLISH_VERIFY_FAILED,
816
- `materialize hook exited with code ${hookResult.exitCode}; payload cannot be trusted`,
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
- // requireReport: stdout-first-json deep compare (declared equals subset).
822
- if (materialize.requireReport) {
823
- const report = parseFirstJsonObject(hookResult.stdout);
824
- if (!report) {
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
- reason: 'report-missing',
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
- 'materialize report missing: no JSON object found on stdout (requireReport.parse = stdout-first-json)',
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
- const equals = materialize.requireReport.equals ?? {};
838
- for (const [key, expectedValue] of Object.entries(equals)) {
839
- if (JSON.stringify(report[key]) !== JSON.stringify(expectedValue)) {
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-mismatch',
844
- mismatchedKeys: Object.keys(equals).filter(
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
- `materialize report mismatch: "${key}" did not meet the frozen requireReport contract`,
851
- { mismatchedKey: key },
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
- // outputMarker: the LAST line containing the marker announces the payload.
858
- const announced = parseOutputMarker(hookResult.stdout, materialize.outputMarker);
859
- if (!announced) {
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: 'failed',
863
- reason: 'marker-missing',
864
- stdoutTail: boundedOutputTail(hookResult.stdout),
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
- { root: worktreePath, env: process.env },
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
- root: worktreePath,
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: { [POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(hookContextProjection) },
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 { validatePostPublishApproval } from '../core/postpublish-approval.mjs';
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) and is validated fail-closed before any write.
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 (planDigest binding,
256
- // declared hook, requiresApproval, 24h window, expiry). A bad approval
257
- // aborts the whole run; a missing one parks the hook at AWAITING_APPROVAL.
258
+ // 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
- root: worktreePath,
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) },