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
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Public payload managed projection (F-06 / T6).
3
+ *
4
+ * Release-domain orchestration ONLY. Full preflight, path containment,
5
+ * lexical classification, transactional writes, rollback, and closure
6
+ * digests all come from the exact Foundation version pinned by
7
+ * `packages/release-skill/package.json` and `pnpm-lock.yaml`
8
+ * `compileProjectionPlan()` / `runProjection()` by package import; strict
9
+ * no-follow source reads come from `skill-family-harness-node`
10
+ * `readFileStrict()`. This module never reimplements a containment check, a
11
+ * preflight, a writer, or a rollback — it supplies exactly the release
12
+ * semantics the Foundation mechanisms do not own:
13
+ *
14
+ * - WHAT is projected: the frozen plan's `postPublish.executionBundle.
15
+ * publicFiles` mapping (schema- and runtime-validated before the plan was
16
+ * frozen). Live project configuration is never read here or downstream —
17
+ * the frozen plan is the only authority, per the R1 timing contract.
18
+ * - WHERE the bytes come from: the detached tag worktree at the frozen
19
+ * tagCommit (snapshot layout — public files live at their `to` paths),
20
+ * read strictly (no-follow, regular-file identity, digest receipt).
21
+ * - WHERE the payload lands: a completely fresh `hub-payload` root inside
22
+ * the execution worktree. The payload root must not pre-exist; a planted
23
+ * symlink or stale directory at that path fails closed before any write.
24
+ * - HOW authority is bound: the frozen mapping bytes ride the projection
25
+ * plan as a `caller-bytes` authority binding (FG-3) — the projection never
26
+ * reads authority from the target root and never forges target-local
27
+ * authority facts; the Kit re-verifies the digest-bound bytes before every
28
+ * mutation.
29
+ *
30
+ * Ordering guarantee: mapping shape checks, strict source staging, and the
31
+ * pure compile step all happen BEFORE the payload root is created — lexical
32
+ * escapes, duplicates, and collisions refuse with zero writes and leave no
33
+ * partial `hub-payload` behind.
34
+ *
35
+ * @module core/postpublish-projection
36
+ */
37
+
38
+ import { chmod, lstat, mkdir, realpath, writeFile } from 'node:fs/promises';
39
+ import { dirname, join } from 'node:path';
40
+
41
+ import {
42
+ buildProjectionClosure,
43
+ compileProjectionPlan,
44
+ runProjection,
45
+ } from 'skill-family-engineering-kit';
46
+ import { digestBytes, readFileStrict } from 'skill-family-harness-node';
47
+
48
+ import { ReleaseError, POST_PUBLISH_VERIFY_FAILED } from './errors.mjs';
49
+
50
+ /** Mechanism identity recorded in the materialize evidence. */
51
+ export const PROJECTION_MECHANISM = 'foundation.engineering-kit.projection';
52
+
53
+ /** The fresh payload root staged inside the execution worktree. */
54
+ export const PUBLIC_PAYLOAD_DIRNAME = 'hub-payload';
55
+
56
+ /** Authority identity of the frozen publicFiles mapping. */
57
+ const AUTHORITY_ID = 'frozen-public-files';
58
+ const AUTHORITY_PATH = 'postpublish-public-files.json';
59
+
60
+ /** Ownership identity recorded for every projected payload file. */
61
+ const OWNER_ID = 'release-skill-postpublish';
62
+
63
+ function fail(message, details = {}) {
64
+ throw new ReleaseError(POST_PUBLISH_VERIFY_FAILED, `public payload projection: ${message}`, details);
65
+ }
66
+
67
+ /**
68
+ * Stage the frozen public payload into a fresh `hub-payload` root inside the
69
+ * execution worktree through the Foundation managed projection.
70
+ *
71
+ * @param {object} params
72
+ * @param {string} params.executionWorktreeRoot - Detached tag worktree root
73
+ * (frozen tagCommit checkout; snapshot layout).
74
+ * @param {string} params.candidateRoot - Disposable staging directory for the
75
+ * candidate closure (external to the payload root; caller owns cleanup).
76
+ * @param {object[]} params.publicFiles - Frozen plan publicFiles mapping.
77
+ * @returns {Promise<{payloadRoot: string, fileCount: number, mechanism: string}>}
78
+ * @throws {ReleaseError} POST_PUBLISH_VERIFY_FAILED on any refusal — after
79
+ * Foundation preflight/rollback semantics, never with a partial payload.
80
+ */
81
+ export async function projectPublicPayload({ executionWorktreeRoot, candidateRoot, publicFiles } = {}) {
82
+ if (!executionWorktreeRoot || typeof executionWorktreeRoot !== 'string') {
83
+ throw new TypeError('projectPublicPayload: executionWorktreeRoot must be a directory path string');
84
+ }
85
+ if (!candidateRoot || typeof candidateRoot !== 'string') {
86
+ throw new TypeError('projectPublicPayload: candidateRoot must be a directory path string');
87
+ }
88
+
89
+ // --- Domain mapping shape (schema-validated upstream; re-checked fail-
90
+ // closed). Lexical path rejection belongs to Foundation classification. ---
91
+ if (!Array.isArray(publicFiles) || publicFiles.length === 0) {
92
+ fail('the frozen plan carries no publicFiles mapping; the payload cannot be staged');
93
+ }
94
+ for (const [index, entry] of publicFiles.entries()) {
95
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)
96
+ || typeof entry.from !== 'string' || entry.from.length === 0
97
+ || typeof entry.to !== 'string' || entry.to.length === 0
98
+ || entry.mode !== 'preserve') {
99
+ fail(`frozen publicFiles entry ${index} is not a valid preserve-mode mapping`, { index });
100
+ }
101
+ }
102
+
103
+ const worktreeReal = await realpath(executionWorktreeRoot).catch(() => null);
104
+ if (!worktreeReal) {
105
+ fail('the execution worktree does not resolve to an existing directory', { executionWorktreeRoot });
106
+ }
107
+
108
+ // --- Payload target preflight: the root must not pre-exist. The tag
109
+ // worktree is disposable and freshly created; a planted symlink or stale
110
+ // directory at the payload path fails closed BEFORE any write. ---
111
+ const payloadPath = join(worktreeReal, PUBLIC_PAYLOAD_DIRNAME);
112
+ const preExisting = await lstat(payloadPath).catch(() => null);
113
+ if (preExisting) {
114
+ fail(`the payload root already exists in the tag worktree (${preExisting.isSymbolicLink() ? 'symbolic link' : 'pre-existing entry'})`, {
115
+ path: PUBLIC_PAYLOAD_DIRNAME,
116
+ });
117
+ }
118
+
119
+ // --- Candidate staging: strict no-follow reads of every frozen `to` source
120
+ // from the frozen worktree (never the live workspace). A symlinked source
121
+ // is refused here — the escape target is never opened. ---
122
+ const stagedResources = [];
123
+ for (const entry of publicFiles) {
124
+ let receipt;
125
+ try {
126
+ receipt = await readFileStrict(executionWorktreeRoot, entry.to);
127
+ } catch (cause) {
128
+ fail(`cannot strictly read the frozen public file "${entry.to}" from the tag worktree: ${cause?.message ?? cause}`, {
129
+ path: entry.to,
130
+ kind: cause?.details?.kind,
131
+ });
132
+ }
133
+ const stagePath = join(candidateRoot, entry.to);
134
+ await mkdir(dirname(stagePath), { recursive: true });
135
+ await writeFile(stagePath, receipt.content);
136
+ await chmod(stagePath, receipt.mode);
137
+ stagedResources.push({ path: entry.to, sha256: receipt.sha256, mode: receipt.mode });
138
+ }
139
+
140
+ // --- Authority binding: the frozen mapping bytes ride the plan as a
141
+ // caller-bytes binding — no authority filesystem access, nothing forged
142
+ // into the target root. ---
143
+ const authorityBytes = Buffer.from(JSON.stringify(publicFiles), 'utf8');
144
+
145
+ // --- Pure compile: containment, duplicates, portable collisions, closure
146
+ // digests — every refusal happens before the payload root exists. ---
147
+ let prepared;
148
+ try {
149
+ prepared = compileProjectionPlan({
150
+ rootBinding: payloadPath,
151
+ authoritySources: [{
152
+ id: AUTHORITY_ID,
153
+ path: AUTHORITY_PATH,
154
+ type: 'file',
155
+ sha256: digestBytes(authorityBytes),
156
+ mode: 0o644,
157
+ }],
158
+ ownership: publicFiles.map((entry) => ({
159
+ path: entry.to,
160
+ authoritySource: AUTHORITY_ID,
161
+ owner: { kind: 'managed', id: OWNER_ID },
162
+ expect: { state: 'absent' },
163
+ })),
164
+ handwrittenPolicy: { authoritySource: AUTHORITY_ID, patterns: [] },
165
+ previousOwnedClosure: buildProjectionClosure([]),
166
+ externalCandidateClosure: buildProjectionClosure(stagedResources),
167
+ authorityBinding: {
168
+ kind: 'caller-bytes',
169
+ bytes: { [AUTHORITY_ID]: authorityBytes.toString('base64') },
170
+ freshRoot: true,
171
+ },
172
+ });
173
+ } catch (cause) {
174
+ fail(`the frozen publicFiles mapping cannot be compiled into a projection plan: ${cause?.message ?? cause}`, {
175
+ kind: cause?.details?.kind,
176
+ });
177
+ }
178
+
179
+ // --- Create the payload root and re-verify its identity (TOCTOU guard:
180
+ // the realpath must equal the compiled rootBinding). ---
181
+ try {
182
+ await mkdir(payloadPath);
183
+ } catch (cause) {
184
+ fail(`cannot create the fresh payload root: ${cause?.message ?? cause}`, { path: PUBLIC_PAYLOAD_DIRNAME });
185
+ }
186
+ const payloadReal = await realpath(payloadPath).catch(() => null);
187
+ if (!payloadReal || payloadReal !== payloadPath) {
188
+ fail('the payload root identity changed during creation (symbolic-link ancestor refused)', {
189
+ path: PUBLIC_PAYLOAD_DIRNAME,
190
+ });
191
+ }
192
+
193
+ // --- Foundation managed projection: complete preflight already passed;
194
+ // runProjection re-verifies candidate, authority, and target expectations
195
+ // before every mutation and restores the complete closure on failure. ---
196
+ try {
197
+ await runProjection({
198
+ root: payloadPath,
199
+ manifest: prepared.manifest,
200
+ candidateRoot,
201
+ preparedProjection: prepared,
202
+ });
203
+ } catch (cause) {
204
+ fail(`projection execution refused: ${cause?.message ?? cause}`, {
205
+ kind: cause?.details?.kind,
206
+ });
207
+ }
208
+
209
+ return { payloadRoot: payloadReal, fileCount: publicFiles.length, mechanism: PROJECTION_MECHANISM };
210
+ }
@@ -29,6 +29,7 @@ import {
29
29
  validatePresetHook,
30
30
  resolvePresetRequiresApproval,
31
31
  } from './presets.mjs';
32
+ import { checkGitRemoteUrl, describeGitRemoteUrlFailure } from './git-url-policy.mjs';
32
33
 
33
34
  /** Secret-ish environment variable denylist (R3 credential hygiene). */
34
35
  export const ENV_ALLOWLIST_DENYLIST = /TOKEN|SECRET|PASSWORD|PASSPHRASE|API_KEY|CREDENTIAL/i;
@@ -39,9 +40,6 @@ const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
39
40
  /** Safe target/step id and step name pattern. */
40
41
  const SAFE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
41
42
 
42
- /** Remote URL pattern: http(s) or file (test transport) and .git-suffixed. */
43
- const REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
44
-
45
43
  /** Branch pattern (leading alphanumeric blocks option-like names). */
46
44
  const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
47
45
 
@@ -121,10 +119,13 @@ function validateTarget(target, index) {
121
119
  if (target.kind !== 'payload-mirror' && target.kind !== 'marketplace-index') {
122
120
  fail(`${where}.kind must be "payload-mirror" or "marketplace-index"`, { kind: target.kind });
123
121
  }
124
- if (typeof target.remoteUrl !== 'string' || !REMOTE_URL_RE.test(target.remoteUrl)) {
125
- fail(`${where}.remoteUrl must be an http(s)/file URL ending in .git`, { remoteUrl: target.remoteUrl });
122
+ const remoteUrlVerdict = checkGitRemoteUrl(target.remoteUrl);
123
+ if (!remoteUrlVerdict.ok) {
124
+ fail(
125
+ `${where}.remoteUrl ${describeGitRemoteUrlFailure(remoteUrlVerdict.reason)}`,
126
+ { reason: remoteUrlVerdict.reason },
127
+ );
126
128
  }
127
- assertNoControlChars(`${where}.remoteUrl`, target.remoteUrl);
128
129
  if (target.visibility !== 'internal' && target.visibility !== 'public') {
129
130
  fail(`${where}.visibility must be "internal" or "public"`, { visibility: target.visibility });
130
131
  }
@@ -366,19 +367,25 @@ export function validatePostPublishDeclaration(postPublish, options = {}) {
366
367
  fail(`${unitLabel}postPublish must be a non-null object`);
367
368
  }
368
369
 
369
- validateHookCommand(`${unitLabel}materialize`, postPublish.materialize);
370
- if (typeof postPublish.materialize.outputMarker !== 'string'
371
- || postPublish.materialize.outputMarker.length === 0) {
372
- fail(`${unitLabel}materialize.outputMarker must be a non-empty string`);
373
- }
374
- assertNoControlChars(`${unitLabel}materialize.outputMarker`, postPublish.materialize.outputMarker);
375
- if (postPublish.materialize.requireReport !== undefined) {
376
- const { parse, equals } = postPublish.materialize.requireReport ?? {};
377
- if (parse !== 'stdout-first-json') {
378
- fail(`${unitLabel}materialize.requireReport.parse must be "stdout-first-json"`);
379
- }
380
- if (equals !== undefined && (typeof equals !== 'object' || Array.isArray(equals) || equals === null)) {
381
- fail(`${unitLabel}materialize.requireReport.equals must be a plain object`);
370
+ // materialize is optional since F-06/T6: a declaration without a consumer
371
+ // materialize hook stages the payload through the frozen publicFiles
372
+ // managed projection (Foundation Engineering Kit). When present, the hook
373
+ // fields stay fail-closed exactly as before.
374
+ if (postPublish.materialize !== undefined) {
375
+ validateHookCommand(`${unitLabel}materialize`, postPublish.materialize);
376
+ if (typeof postPublish.materialize.outputMarker !== 'string'
377
+ || postPublish.materialize.outputMarker.length === 0) {
378
+ fail(`${unitLabel}materialize.outputMarker must be a non-empty string`);
379
+ }
380
+ assertNoControlChars(`${unitLabel}materialize.outputMarker`, postPublish.materialize.outputMarker);
381
+ if (postPublish.materialize.requireReport !== undefined) {
382
+ const { parse, equals } = postPublish.materialize.requireReport ?? {};
383
+ if (parse !== 'stdout-first-json') {
384
+ fail(`${unitLabel}materialize.requireReport.parse must be "stdout-first-json"`);
385
+ }
386
+ if (equals !== undefined && (typeof equals !== 'object' || Array.isArray(equals) || equals === null)) {
387
+ fail(`${unitLabel}materialize.requireReport.equals must be a plain object`);
388
+ }
382
389
  }
383
390
  }
384
391
 
@@ -35,6 +35,16 @@ import { executeDocsRefreshHook } from './docs-refresh-preset.mjs';
35
35
  /**
36
36
  * Execute one preset hook end-to-end.
37
37
  *
38
+ * F-04 root split: this seam receives `releaseWorkspaceRoot` — the real
39
+ * project root the user releases from. It is forwarded to proposal-inbox
40
+ * (local-file), marketplace-registry-entry, docs-refresh and the shared
41
+ * workspace preflight (§2.6) as the ONLY `target.workspace` resolution basis
42
+ * and release-workspace write-exclusion basis. The detached frozen-tag
43
+ * worktree is the execution worktree (materialize/steps/custom command
44
+ * hooks) and is NEVER accepted here: the legacy ambiguous `root` option is
45
+ * rejected fail-closed, and neither root falls back onto the other through
46
+ * defaults.
47
+ *
38
48
  * @param {object} params
39
49
  * @param {object} params.hook - Declared hook entry (preset + config).
40
50
  * @param {object} params.contextProjection - The §2.3 context projection of
@@ -43,8 +53,8 @@ import { executeDocsRefreshHook } from './docs-refresh-preset.mjs';
43
53
  * projection for proposal documents (byte-deterministic redelivery);
44
54
  * defaults to contextProjection.
45
55
  * @param {object} params.commitIdentity - Frozen postPublish commitIdentity.
46
- * @param {string} params.root - Release workspace root (preset workspace
47
- * resolution; hooks run inside the frozen tag worktree).
56
+ * @param {string} params.releaseWorkspaceRoot - Release workspace root (the
57
+ * real project root; preset workspace resolution + write exclusion).
48
58
  * @param {string} [params.evidencePath] - This run's evidence path
49
59
  * (notify-handoff checklist rendering).
50
60
  * @param {Function} [params.exec] - Injectable git exec (tests).
@@ -59,7 +69,7 @@ export async function executePresetHook(params) {
59
69
  contextProjection,
60
70
  proposalContextProjection,
61
71
  commitIdentity,
62
- root,
72
+ releaseWorkspaceRoot,
63
73
  evidencePath,
64
74
  exec,
65
75
  hookRunner,
@@ -68,6 +78,15 @@ export async function executePresetHook(params) {
68
78
  if (!hook || typeof hook.preset !== 'string') {
69
79
  throw new ReleaseError(POST_PUBLISH_VERIFY_FAILED, 'preset execution requires a preset hook entry');
70
80
  }
81
+ if (params?.root !== undefined) {
82
+ // F-04: the ambiguous `root` is gone. Silently honoring it would let the
83
+ // detached execution worktree impersonate the release workspace again.
84
+ throw new ReleaseError(
85
+ POST_PUBLISH_VERIFY_FAILED,
86
+ 'executePresetHook no longer accepts the ambiguous "root" option; pass releaseWorkspaceRoot (the real project root) explicitly — the detached tag worktree is the execution worktree for materialize/steps/custom command hooks and is never the release workspace',
87
+ {},
88
+ );
89
+ }
71
90
  const proposalProjection = proposalContextProjection ?? contextProjection;
72
91
 
73
92
  switch (hook.preset) {
@@ -108,7 +127,7 @@ export async function executePresetHook(params) {
108
127
  hook,
109
128
  contextProjection: proposalProjection,
110
129
  commitIdentity,
111
- root,
130
+ releaseWorkspaceRoot,
112
131
  ...(exec !== undefined ? { exec } : {}),
113
132
  });
114
133
  }
@@ -124,7 +143,7 @@ export async function executePresetHook(params) {
124
143
  hook,
125
144
  contextProjection,
126
145
  commitIdentity,
127
- root,
146
+ releaseWorkspaceRoot,
128
147
  ...(exec !== undefined ? { exec } : {}),
129
148
  ...(hookRunner !== undefined ? { hookRunner } : {}),
130
149
  });
@@ -136,7 +155,7 @@ export async function executePresetHook(params) {
136
155
  contextProjection,
137
156
  commitIdentity,
138
157
  payloadDir,
139
- root,
158
+ releaseWorkspaceRoot,
140
159
  ...(exec !== undefined ? { exec } : {}),
141
160
  ...(hookRunner !== undefined ? { hookRunner } : {}),
142
161
  });
@@ -41,6 +41,12 @@ import {
41
41
  } from './errors.mjs';
42
42
  import { preflightPresetWorkspace, assertPresetWorkspaceExecution } from './presets.mjs';
43
43
  import { POSTPUBLISH_CONTEXT_ENV } from './postpublish.mjs';
44
+ import {
45
+ checkGitRemoteUrl,
46
+ describeGitRemoteUrlFailure,
47
+ isAllowedGitRemoteUrl,
48
+ redactUrlCredentialsIfPresent,
49
+ } from './git-url-policy.mjs';
44
50
 
45
51
  const execFileAsync = promisify(execFileCb);
46
52
 
@@ -49,7 +55,6 @@ const TRANSFER_TIMEOUT_MS = 300_000;
49
55
  const PROBE_TIMEOUT_MS = 30_000;
50
56
  const TMP_PREFIX = 'release-skill-preset-write-';
51
57
 
52
- const SAFE_REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
53
58
  const SAFE_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
54
59
  const SHA_RE = /^[a-f0-9]{40}$/;
55
60
 
@@ -94,8 +99,13 @@ function hasControlChars(value) {
94
99
  }
95
100
 
96
101
  function assertSafeRemoteUrl(remoteUrl) {
97
- if (typeof remoteUrl !== 'string' || !SAFE_REMOTE_URL_RE.test(remoteUrl) || hasControlChars(remoteUrl)) {
98
- throw new ReleaseError(GATE_FAILED, 'preset downstream remoteUrl must be an http(s)/file URL ending in .git', { remoteUrl });
102
+ const verdict = checkGitRemoteUrl(remoteUrl);
103
+ if (!verdict.ok) {
104
+ throw new ReleaseError(
105
+ GATE_FAILED,
106
+ `preset downstream remoteUrl ${describeGitRemoteUrlFailure(verdict.reason)}`,
107
+ { reason: verdict.reason },
108
+ );
99
109
  }
100
110
  }
101
111
 
@@ -177,7 +187,7 @@ export async function crossCheckBranchTip(exec, remoteUrl, branch, pushedCommit)
177
187
  throw new ReleaseError(
178
188
  POST_PUBLISH_VERIFY_FAILED,
179
189
  `post-push cross-check failed: downstream branch ${branch} is at ${observed ?? '<missing>'}, which disagrees with the pushed commit ${pushedCommit}`,
180
- { remoteUrl, branch, pushedCommit, observed },
190
+ { remoteUrl: redactUrlCredentialsIfPresent(remoteUrl), branch, pushedCommit, observed },
181
191
  );
182
192
  }
183
193
  return observed;
@@ -198,7 +208,8 @@ export async function crossCheckBranchTip(exec, remoteUrl, branch, pushedCommit)
198
208
  * preset's files into the worktree.
199
209
  * @param {object[]} [params.gates] - Downstream gates (argument arrays).
200
210
  * @param {object} [params.contextProjection] - Injected into gate env.
201
- * @param {string} params.root - Release workspace root (workspace addressing).
211
+ * @param {string} params.releaseWorkspaceRoot - Release workspace root (the
212
+ * real project root; workspace addressing resolution + write exclusion).
202
213
  * @param {Function} [params.exec] - Injectable git exec (tests).
203
214
  * @param {Function} [params.hookRunner] - Injectable gate runner (tests).
204
215
  * @returns {Promise<{ status: 'EXECUTED'|'NO_CHANGE', observation: object }>}
@@ -215,7 +226,7 @@ export async function applyDownstreamGitChange(params) {
215
226
  mutate,
216
227
  gates = [],
217
228
  contextProjection,
218
- root,
229
+ releaseWorkspaceRoot,
219
230
  exec: execOpt,
220
231
  hookRunner,
221
232
  } = params ?? {};
@@ -258,12 +269,13 @@ export async function applyDownstreamGitChange(params) {
258
269
  } catch (error) {
259
270
  await rm(worktree, { recursive: true, force: true }).catch(() => {});
260
271
  const classification = classifyNetFailure(stderrText(error));
272
+ const safeRemoteUrl = redactUrlCredentialsIfPresent(target.remoteUrl);
261
273
  throw new ReleaseError(
262
274
  classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE,
263
275
  classification === 'auth'
264
- ? `preset downstream remote refused authentication for ${target.remoteUrl}; the host keychain credential is reused and never prompted, read, or retried`
265
- : `cannot reach preset downstream remote ${target.remoteUrl} — start VPN / check network; delivery fails closed and never retries with credentials`,
266
- { remoteUrl: target.remoteUrl, stderrTail: stderrTail(error) },
276
+ ? `preset downstream remote refused authentication for ${safeRemoteUrl}; the host keychain credential is reused and never prompted, read, or retried`
277
+ : `cannot reach preset downstream remote ${safeRemoteUrl} — start VPN / check network; delivery fails closed and never retries with credentials`,
278
+ { remoteUrl: safeRemoteUrl, stderrTail: stderrTail(error) },
267
279
  );
268
280
  }
269
281
  // Continue the declared branch when it exists; start a root commit otherwise.
@@ -281,9 +293,17 @@ export async function applyDownstreamGitChange(params) {
281
293
  } else {
282
294
  // Workspace addressing: §2.6 execution checks + a branch guard. The
283
295
  // checkout is used in place (no clone); the branch is never switched.
284
- const preflight = await preflightPresetWorkspace(target.workspace, { root });
296
+ // F-04: the resolution basis is the release workspace root — never the
297
+ // detached execution worktree.
298
+ if (typeof releaseWorkspaceRoot !== 'string' || releaseWorkspaceRoot.length === 0) {
299
+ throw new ReleaseError(
300
+ GATE_FAILED,
301
+ 'preset downstream workspace addressing requires the release workspace root (releaseWorkspaceRoot); the detached execution worktree is never the release workspace',
302
+ );
303
+ }
304
+ const preflight = await preflightPresetWorkspace(target.workspace, { releaseWorkspaceRoot });
285
305
  const execution = await assertPresetWorkspaceExecution(target.workspace, {
286
- root,
306
+ releaseWorkspaceRoot,
287
307
  preflightRealpath: preflight.realpath,
288
308
  });
289
309
  worktree = execution.realpath;
@@ -403,11 +423,12 @@ export async function applyDownstreamGitChange(params) {
403
423
  });
404
424
  } catch (error) {
405
425
  const classification = classifyNetFailure(stderrText(error));
426
+ const safeTargetAddress = redactUrlCredentialsIfPresent(target.remoteUrl ?? target.workspace);
406
427
  throw new ReleaseError(
407
428
  classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE,
408
429
  classification === 'auth'
409
- ? `preset downstream push refused authentication for ${target.remoteUrl ?? target.workspace}; the host keychain credential is reused and never prompted, read, or retried`
410
- : `preset downstream push to ${target.remoteUrl ?? target.workspace} failed — start VPN / check network; delivery fails closed and never retries with credentials`,
430
+ ? `preset downstream push refused authentication for ${safeTargetAddress}; the host keychain credential is reused and never prompted, read, or retried`
431
+ : `preset downstream push to ${safeTargetAddress} failed — start VPN / check network; delivery fails closed and never retries with credentials`,
411
432
  { branch: target.branch, stderrTail: stderrTail(error) },
412
433
  );
413
434
  }
@@ -429,12 +450,12 @@ export async function applyDownstreamGitChange(params) {
429
450
  if (!crossCheckUrl) {
430
451
  crossCheckSkipReason =
431
452
  'workspace checkout declares no origin remote; the post-push ls-remote cross-check needs a remote URL';
432
- } else if (!SAFE_REMOTE_URL_RE.test(crossCheckUrl)) {
453
+ } else if (!isAllowedGitRemoteUrl(crossCheckUrl)) {
433
454
  crossCheckSkipReason =
434
- 'workspace origin URL is not an http(s)/file URL (SSH and other transports are outside the ls-remote cross-check policy); post-push cross-check skipped';
455
+ 'workspace origin URL is not a policy-allowed http(s)/file URL (SSH and other transports, or credential-embedded forms, are outside the ls-remote cross-check policy); post-push cross-check skipped';
435
456
  }
436
457
  }
437
- if (crossCheckUrl && SAFE_REMOTE_URL_RE.test(crossCheckUrl)) {
458
+ if (crossCheckUrl && isAllowedGitRemoteUrl(crossCheckUrl)) {
438
459
  await crossCheckBranchTip(exec, crossCheckUrl, target.branch, localCommit);
439
460
  }
440
461
 
@@ -57,10 +57,10 @@
57
57
  import { realpath, stat } from 'node:fs/promises';
58
58
  import { isAbsolute, resolve } from 'node:path';
59
59
 
60
- import { ReleaseError, GATE_FAILED, POSTPUBLISH_HOOK_INVALID } from './errors.mjs';
60
+ import { classifyPathInput } from 'skill-family-harness-node';
61
61
 
62
- /** Remote URL pattern shared with core/postpublish.mjs target validation. */
63
- const REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
62
+ import { ReleaseError, GATE_FAILED, POSTPUBLISH_HOOK_INVALID } from './errors.mjs';
63
+ import { checkGitRemoteUrl, describeGitRemoteUrlFailure } from './git-url-policy.mjs';
64
64
 
65
65
  /** Branch pattern (leading alphanumeric blocks option-like names). */
66
66
  const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
@@ -160,12 +160,13 @@ function validateDownstreamTarget(where, target, { targetOptional }) {
160
160
  );
161
161
  }
162
162
  if (hasRemoteUrl) {
163
- if (typeof target.remoteUrl !== 'string' || !REMOTE_URL_RE.test(target.remoteUrl)) {
164
- failHook(`${where}.config.target.remoteUrl must be an http(s)/file URL ending in .git`, {
165
- remoteUrl: target.remoteUrl,
166
- });
163
+ const remoteUrlVerdict = checkGitRemoteUrl(target.remoteUrl);
164
+ if (!remoteUrlVerdict.ok) {
165
+ failHook(
166
+ `${where}.config.target.remoteUrl ${describeGitRemoteUrlFailure(remoteUrlVerdict.reason)}`,
167
+ { reason: remoteUrlVerdict.reason },
168
+ );
167
169
  }
168
- assertNoControlChars(`${where}.config.target.remoteUrl`, target.remoteUrl);
169
170
  } else {
170
171
  if (typeof target.workspace !== 'string' || target.workspace.length === 0) {
171
172
  failHook(`${where}.config.target.workspace must be a non-empty string`);
@@ -632,24 +633,61 @@ export function validatePresetHook(hook, where) {
632
633
 
633
634
  // ---------------------------------------------------------------------------
634
635
  // Workspace preflight + execution checks (§2.6, three execution rules)
636
+ //
637
+ // F-04 root split: these checks receive `releaseWorkspaceRoot` — the real
638
+ // project root the user releases from. It is the ONLY resolution basis for
639
+ // preset `config.target.workspace` and the ONLY basis for the release-
640
+ // workspace write exclusion. The detached frozen-tag worktree is the
641
+ // execution worktree (materialize/steps/custom command hooks) and must never
642
+ // be passed here: the two roots never fall back onto each other.
635
643
  // ---------------------------------------------------------------------------
636
644
 
637
645
  function failWorkspace(message, details = {}) {
638
646
  throw new ReleaseError(GATE_FAILED, `preset workspace invalid: ${message}`, details);
639
647
  }
640
648
 
649
+ /**
650
+ * Resolve a declared workspace path against the release workspace root
651
+ * (F-04). Absolute workspaces pass through (preset-level exception); relative
652
+ * workspaces are lexically classified through Foundation `classifyPathInput`
653
+ * (ambiguous cross-platform shapes fail closed; no parallel path regex here)
654
+ * and resolved from `releaseWorkspaceRoot`.
655
+ *
656
+ * @param {string} workspace - Declared workspace path.
657
+ * @param {string} releaseWorkspaceRoot - The real release workspace root.
658
+ * @returns {string} The resolved (not yet realpathed) workspace path.
659
+ */
660
+ function resolvePresetWorkspace(workspace, releaseWorkspaceRoot) {
661
+ if (typeof releaseWorkspaceRoot !== 'string' || releaseWorkspaceRoot.length === 0) {
662
+ failWorkspace(
663
+ 'preset workspace checks require the release workspace root (releaseWorkspaceRoot); the detached execution worktree is never the release workspace',
664
+ {},
665
+ );
666
+ }
667
+ if (isAbsolute(workspace)) return workspace;
668
+ const classification = classifyPathInput(workspace);
669
+ if (!classification.ok) {
670
+ failWorkspace('workspace is not an unambiguous path input', {
671
+ workspace,
672
+ kind: classification.kind,
673
+ });
674
+ }
675
+ return resolve(releaseWorkspaceRoot, workspace);
676
+ }
677
+
641
678
  /**
642
679
  * Preflight a `config.target.workspace` before any write: the path must
643
680
  * exist and be a git worktree (a `.git` entry), otherwise fail closed.
644
681
  * Returns the resolved realpath for evidence and the TOCTOU re-check.
645
682
  *
646
- * @param {string} workspace - Declared workspace path (relative to `root`
647
- * or absolute; may leave the repository root by preset-level exception).
648
- * @param {object} options - { root: string }
683
+ * @param {string} workspace - Declared workspace path (relative to
684
+ * `releaseWorkspaceRoot` or absolute; may leave the release workspace root
685
+ * by preset-level exception).
686
+ * @param {object} options - { releaseWorkspaceRoot: string }
649
687
  * @returns {Promise<{ realpath: string }>}
650
688
  */
651
- export async function preflightPresetWorkspace(workspace, { root }) {
652
- const resolved = isAbsolute(workspace) ? workspace : resolve(root, workspace);
689
+ export async function preflightPresetWorkspace(workspace, { releaseWorkspaceRoot }) {
690
+ const resolved = resolvePresetWorkspace(workspace, releaseWorkspaceRoot);
653
691
  let real;
654
692
  try {
655
693
  real = await realpath(resolved);
@@ -674,14 +712,16 @@ export async function preflightPresetWorkspace(workspace, { root }) {
674
712
  * 2. it MUST equal the preflight realpath (TOCTOU: the path may not have
675
713
  * been swapped for a symlink since preflight);
676
714
  * 3. it must be neither the release workspace itself nor inside the
677
- * `.release-skill/` runtime directory.
715
+ * `.release-skill/` runtime directory. The release-workspace comparison
716
+ * uses `releaseWorkspaceRoot` (F-04): comparing against the detached
717
+ * execution worktree would let a preset write into the real project.
678
718
  *
679
719
  * @param {string} workspace - Declared workspace path.
680
- * @param {object} options - { root, preflightRealpath }
720
+ * @param {object} options - { releaseWorkspaceRoot, preflightRealpath }
681
721
  * @returns {Promise<{ realpath: string }>}
682
722
  */
683
- export async function assertPresetWorkspaceExecution(workspace, { root, preflightRealpath }) {
684
- const resolved = isAbsolute(workspace) ? workspace : resolve(root, workspace);
723
+ export async function assertPresetWorkspaceExecution(workspace, { releaseWorkspaceRoot, preflightRealpath }) {
724
+ const resolved = resolvePresetWorkspace(workspace, releaseWorkspaceRoot);
685
725
  let real;
686
726
  try {
687
727
  real = await realpath(resolved);
@@ -694,8 +734,16 @@ export async function assertPresetWorkspaceExecution(workspace, { root, prefligh
694
734
  { workspace, preflightRealpath, observedRealpath: real },
695
735
  );
696
736
  }
697
- const rootReal = await realpath(root).catch(() => null);
698
- if (rootReal && real === rootReal) {
737
+ let rootReal;
738
+ try {
739
+ rootReal = await realpath(releaseWorkspaceRoot);
740
+ } catch {
741
+ failWorkspace(
742
+ 'release workspace root does not resolve to an existing directory; the release-workspace write exclusion fails closed',
743
+ {},
744
+ );
745
+ }
746
+ if (real === rootReal) {
699
747
  failWorkspace('workspace must not be the release workspace itself', { workspace });
700
748
  }
701
749
  const segments = real.split(/[\\/]+/);