release-skill 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) 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 +23 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/INSTALL.md +47 -2
  9. package/INSTALL.zh-CN.md +29 -2
  10. package/README.md +126 -9
  11. package/README.zh-CN.md +108 -9
  12. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  13. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  14. package/adapters/claude/bin/release-skill.bundle.mjs +6408 -1654
  15. package/adapters/claude/schemas/.render-manifest.json +10 -6
  16. package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
  17. package/adapters/claude/schemas/release-plan.schema.json +65 -1
  18. package/adapters/claude/schemas/release-project.schema.json +73 -1
  19. package/adapters/claude/schemas/release-run.schema.json +11 -6
  20. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  21. package/adapters/codex/bin/release-skill.bundle.mjs +6408 -1654
  22. package/adapters/codex/schemas/.render-manifest.json +10 -6
  23. package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
  24. package/adapters/codex/schemas/release-plan.schema.json +65 -1
  25. package/adapters/codex/schemas/release-project.schema.json +73 -1
  26. package/adapters/codex/schemas/release-run.schema.json +11 -6
  27. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  28. package/adapters/kimi/bin/release-skill.bundle.mjs +6408 -1654
  29. package/adapters/kimi/schemas/.render-manifest.json +10 -6
  30. package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
  31. package/adapters/kimi/schemas/release-plan.schema.json +65 -1
  32. package/adapters/kimi/schemas/release-project.schema.json +73 -1
  33. package/adapters/kimi/schemas/release-run.schema.json +11 -6
  34. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  35. package/adapters/workbuddy/bin/release-skill.bundle.mjs +6408 -1654
  36. package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
  37. package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
  38. package/adapters/workbuddy/schemas/release-plan.schema.json +65 -1
  39. package/adapters/workbuddy/schemas/release-project.schema.json +73 -1
  40. package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
  41. package/bin/release-skill-cli.mjs +181 -3
  42. package/bin/release-skill.bundle.mjs +6408 -1654
  43. package/package.json +2 -1
  44. package/platform-manifest.json +4 -4
  45. package/references/.render-manifest.json +5 -5
  46. package/references/01-state-machine.md +22 -2
  47. package/schemas/.render-manifest.json +10 -6
  48. package/schemas/postpublish-approval-record.schema.json +47 -0
  49. package/schemas/release-plan.schema.json +65 -1
  50. package/schemas/release-project.schema.json +73 -1
  51. package/schemas/release-run.schema.json +11 -6
  52. package/src/commands/approve.mjs +167 -1
  53. package/src/commands/distribute.mjs +411 -33
  54. package/src/commands/postverify.mjs +734 -0
  55. package/src/commands/prepare.mjs +280 -42
  56. package/src/commands/setup.mjs +715 -0
  57. package/src/commands/ship.mjs +152 -5
  58. package/src/commands/verify.mjs +92 -15
  59. package/src/core/approval.mjs +93 -68
  60. package/src/core/bounded-output.mjs +46 -0
  61. package/src/core/derived-artifact-gates.mjs +258 -0
  62. package/src/core/docs-refresh-preset.mjs +167 -0
  63. package/src/core/errors.mjs +4 -0
  64. package/src/core/hooks.mjs +28 -0
  65. package/src/core/marketplace-registry-entry.mjs +174 -0
  66. package/src/core/notify-handoff.mjs +76 -0
  67. package/src/core/postpublish-approval.mjs +110 -0
  68. package/src/core/postpublish.mjs +424 -7
  69. package/src/core/preset-executor.mjs +156 -0
  70. package/src/core/preset-gitwrite.mjs +463 -0
  71. package/src/core/presets.mjs +706 -0
  72. package/src/core/proposal-inbox.mjs +630 -0
  73. package/src/core/run.mjs +91 -6
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Preset hook execution dispatcher (v0.6.3 R4, design §2.5).
3
+ *
4
+ * One dispatch seam shared by the distribute saga and the independent
5
+ * postVerify run: every registered preset routes to its built-in
6
+ * implementation here, so both phases can never drift apart.
7
+ *
8
+ * Result contract (normalized across presets):
9
+ * - `status`: 'EXECUTED' | 'NO_CHANGE';
10
+ * - `mode`: checkpoint/evidence delivery mode ('pushed' | 'local-file' |
11
+ * 'notify-handoff' | 'no-change' | ...);
12
+ * - `observation`: preset-specific observation (pushedCommit, ...);
13
+ * - optional `manualSyncPrompt` (proposal-inbox closure semantics and the
14
+ * notify-handoff checklist text), `checklist` (notify-handoff lines),
15
+ * `degradedToNotifyHandoff` (proposal-inbox without a target).
16
+ *
17
+ * proposal-inbox routing (targetOptional, review N-B1): without a target the
18
+ * preset DEGRADES to notify-handoff behavior instead of erroring; with a
19
+ * target the transport follows the addressing unless config.delivery pins it
20
+ * (remoteUrl -> git-push, workspace -> local-file).
21
+ *
22
+ * @module core/preset-executor
23
+ */
24
+
25
+ import { ReleaseError, POST_PUBLISH_VERIFY_FAILED } from './errors.mjs';
26
+ import { resolveProposalInboxTransport } from './presets.mjs';
27
+ import { executeNotifyHandoffHook } from './notify-handoff.mjs';
28
+ import {
29
+ executeProposalInboxGitPushHook,
30
+ executeProposalInboxLocalFileHook,
31
+ } from './proposal-inbox.mjs';
32
+ import { executeMarketplaceRegistryEntryHook } from './marketplace-registry-entry.mjs';
33
+ import { executeDocsRefreshHook } from './docs-refresh-preset.mjs';
34
+
35
+ /**
36
+ * Execute one preset hook end-to-end.
37
+ *
38
+ * @param {object} params
39
+ * @param {object} params.hook - Declared hook entry (preset + config).
40
+ * @param {object} params.contextProjection - The §2.3 context projection of
41
+ * the CURRENT run (notify-handoff checklists, docs/marketplace writes).
42
+ * @param {object} [params.proposalContextProjection] - Lineage-stable
43
+ * projection for proposal documents (byte-deterministic redelivery);
44
+ * defaults to contextProjection.
45
+ * @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).
48
+ * @param {string} [params.evidencePath] - This run's evidence path
49
+ * (notify-handoff checklist rendering).
50
+ * @param {Function} [params.exec] - Injectable git exec (tests).
51
+ * @param {Function} [params.hookRunner] - Injectable hook runner (tests).
52
+ * @param {string} [params.payloadDir] - Materialized payload directory
53
+ * (docs-refresh mappings; distribute phase only).
54
+ * @returns {Promise<object>} Normalized delivery result (see module header).
55
+ */
56
+ export async function executePresetHook(params) {
57
+ const {
58
+ hook,
59
+ contextProjection,
60
+ proposalContextProjection,
61
+ commitIdentity,
62
+ root,
63
+ evidencePath,
64
+ exec,
65
+ hookRunner,
66
+ payloadDir,
67
+ } = params ?? {};
68
+ if (!hook || typeof hook.preset !== 'string') {
69
+ throw new ReleaseError(POST_PUBLISH_VERIFY_FAILED, 'preset execution requires a preset hook entry');
70
+ }
71
+ const proposalProjection = proposalContextProjection ?? contextProjection;
72
+
73
+ switch (hook.preset) {
74
+ case 'notify-handoff': {
75
+ return executeNotifyHandoffHook({
76
+ contextProjection,
77
+ ...(evidencePath !== undefined ? { evidencePath } : {}),
78
+ });
79
+ }
80
+
81
+ case 'proposal-inbox': {
82
+ const target = hook.config?.target;
83
+ if (!target) {
84
+ // targetOptional degradation (N-B1): notify-handoff behavior, never
85
+ // an error — evidenced as such, never silent.
86
+ const degraded = await executeNotifyHandoffHook({
87
+ contextProjection,
88
+ ...(evidencePath !== undefined ? { evidencePath } : {}),
89
+ });
90
+ return { ...degraded, degradedToNotifyHandoff: true };
91
+ }
92
+ // Effective transport (single authority: core/presets.mjs, shared with
93
+ // the requiresApproval grading — R4 review M-1). Presets have NO
94
+ // dry-run path of their own: the command-level DRY_RUN skip contract
95
+ // skips preset hooks wholesale (SKIPPED/DRY_RUN checkpoints) before
96
+ // this dispatch is ever reached (R4 review m-3).
97
+ const transport = resolveProposalInboxTransport(hook.config);
98
+ if (transport === 'git-push') {
99
+ return executeProposalInboxGitPushHook({
100
+ hook,
101
+ contextProjection: proposalProjection,
102
+ commitIdentity,
103
+ ...(exec !== undefined ? { exec } : {}),
104
+ });
105
+ }
106
+ if (transport === 'local-file') {
107
+ return executeProposalInboxLocalFileHook({
108
+ hook,
109
+ contextProjection: proposalProjection,
110
+ commitIdentity,
111
+ root,
112
+ ...(exec !== undefined ? { exec } : {}),
113
+ });
114
+ }
115
+ throw new ReleaseError(
116
+ POST_PUBLISH_VERIFY_FAILED,
117
+ `proposal-inbox transport "${transport}" is unknown (expected git-push or local-file)`,
118
+ { transport },
119
+ );
120
+ }
121
+
122
+ case 'marketplace-registry-entry': {
123
+ return executeMarketplaceRegistryEntryHook({
124
+ hook,
125
+ contextProjection,
126
+ commitIdentity,
127
+ root,
128
+ ...(exec !== undefined ? { exec } : {}),
129
+ ...(hookRunner !== undefined ? { hookRunner } : {}),
130
+ });
131
+ }
132
+
133
+ case 'docs-refresh': {
134
+ return executeDocsRefreshHook({
135
+ hook,
136
+ contextProjection,
137
+ commitIdentity,
138
+ payloadDir,
139
+ root,
140
+ ...(exec !== undefined ? { exec } : {}),
141
+ ...(hookRunner !== undefined ? { hookRunner } : {}),
142
+ });
143
+ }
144
+
145
+ default: {
146
+ // Fail-closed: an unimplemented preset never executes. Declaration
147
+ // validation rejects unknown preset names, so this only guards presets
148
+ // registered but not yet shipped.
149
+ throw new ReleaseError(
150
+ POST_PUBLISH_VERIFY_FAILED,
151
+ `hook "${hook.id}" is preset "${hook.preset}"; this preset's behavior is not yet available in this release and ships in a later release`,
152
+ { preset: hook.preset },
153
+ );
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Shared downstream git-write lifecycle for R4 write-downstream presets
3
+ * (marketplace-registry-entry, docs-refresh; design §2.5/§2.6).
4
+ *
5
+ * Both presets update an EXISTING downstream repository and push the result.
6
+ * They share one lifecycle so the safety semantics can never drift:
7
+ *
8
+ * - addressing (§2.5 dual addressing): `target.remoteUrl` clones a fresh
9
+ * worktree (zero local layout assumptions); `target.workspace` reuses a
10
+ * local checkout in place after the §2.6 execution checks (preflight
11
+ * realpath, TOCTOU re-check, release-workspace/runtime exclusion) and a
12
+ * checked-out-branch guard;
13
+ * - observe-before-write: the staged tree is compared against the branch tip
14
+ * AFTER the preset's mutation; identical -> NO_CHANGE (no commit, no push,
15
+ * no gates — an idempotent repeat reports honestly);
16
+ * - downstream gates (argument arrays, the R1 hook runner) run inside the
17
+ * worktree AFTER the mutation and BEFORE any commit/push; a non-zero gate
18
+ * fails closed with zero remote side effects;
19
+ * - commit uses the frozen bot identity injected per-command (`git -c
20
+ * user.name=… -c user.email=…`, never touching global git config); the push
21
+ * is plain (NEVER --force); GIT_TERMINAL_PROMPT=0 everywhere — the host
22
+ * credential helper answers or delivery fails fast; credentials are never
23
+ * read, printed, or retried;
24
+ * - after a push, `git ls-remote` cross-checks the branch tip (review N-3).
25
+ *
26
+ * @module core/preset-gitwrite
27
+ */
28
+
29
+ import { execFile as execFileCb } from 'node:child_process';
30
+ import { promisify } from 'node:util';
31
+ import { mkdtemp, rm } from 'node:fs/promises';
32
+ import { tmpdir } from 'node:os';
33
+ import { join } from 'node:path';
34
+
35
+ import {
36
+ ReleaseError,
37
+ GATE_FAILED,
38
+ REMOTE_CONFLICT,
39
+ REMOTE_UNAVAILABLE,
40
+ POST_PUBLISH_VERIFY_FAILED,
41
+ } from './errors.mjs';
42
+ import { preflightPresetWorkspace, assertPresetWorkspaceExecution } from './presets.mjs';
43
+ import { POSTPUBLISH_CONTEXT_ENV } from './postpublish.mjs';
44
+
45
+ const execFileAsync = promisify(execFileCb);
46
+
47
+ const GIT_TIMEOUT_MS = 120_000;
48
+ const TRANSFER_TIMEOUT_MS = 300_000;
49
+ const PROBE_TIMEOUT_MS = 30_000;
50
+ const TMP_PREFIX = 'release-skill-preset-write-';
51
+
52
+ const SAFE_REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
53
+ const SAFE_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
54
+ const SHA_RE = /^[a-f0-9]{40}$/;
55
+
56
+ /** Credential hygiene: git is never allowed to prompt. */
57
+ const NEVER_PROMPT_ENV = { GIT_TERMINAL_PROMPT: '0' };
58
+
59
+ const AUTH_FAILURE_PATTERNS = [
60
+ /authentication failed/i,
61
+ /could not read username/i,
62
+ /could not read password/i,
63
+ /terminal prompts disabled/i,
64
+ /invalid username or password/i,
65
+ /permission denied/i,
66
+ /access denied/i,
67
+ /authorization failed/i,
68
+ /\b403\b/,
69
+ /\b401\b/,
70
+ ];
71
+
72
+ const TRANSPORT_FAILURE_PATTERNS = [
73
+ /could not resolve host/i,
74
+ /unable to access/i,
75
+ /connection refused/i,
76
+ /connection timed out/i,
77
+ /operation timed out/i,
78
+ /network is unreachable/i,
79
+ /ssl certificate problem/i,
80
+ /could not resolve proxy/i,
81
+ /failed to connect/i,
82
+ ];
83
+
84
+ function defaultExec(command, args, options = {}) {
85
+ return execFileAsync(command, args, { shell: false, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, ...options });
86
+ }
87
+
88
+ function netEnv() {
89
+ return { ...process.env, ...NEVER_PROMPT_ENV };
90
+ }
91
+
92
+ function hasControlChars(value) {
93
+ return /[\x00-\x1f\x7f]/.test(value);
94
+ }
95
+
96
+ 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 });
99
+ }
100
+ }
101
+
102
+ function assertSafeBranch(branch) {
103
+ if (typeof branch !== 'string' || !SAFE_BRANCH_RE.test(branch)
104
+ || branch.includes('..') || branch.endsWith('.') || branch.endsWith('.lock')) {
105
+ throw new ReleaseError(GATE_FAILED, 'preset downstream branch is not a safe Git branch name', { branch });
106
+ }
107
+ }
108
+
109
+ function assertCommitIdentity(identity) {
110
+ if (!identity || typeof identity !== 'object') {
111
+ throw new ReleaseError(GATE_FAILED, 'preset downstream write requires a frozen commitIdentity');
112
+ }
113
+ for (const field of ['name', 'email']) {
114
+ const value = identity[field];
115
+ if (typeof value !== 'string' || value.length === 0 || value.startsWith('-') || hasControlChars(value)) {
116
+ throw new ReleaseError(GATE_FAILED, `preset downstream commitIdentity.${field} is missing or unsafe`, { field });
117
+ }
118
+ }
119
+ }
120
+
121
+ function stderrText(error) {
122
+ return `${error?.stderr ?? ''}\n${error?.message ?? ''}`;
123
+ }
124
+
125
+ function stderrTail(error, limit = 400) {
126
+ const text = stderrText(error).trim();
127
+ return text.length > limit ? `…${text.slice(-limit)}` : text;
128
+ }
129
+
130
+ function classifyNetFailure(text) {
131
+ const output = typeof text === 'string' ? text : '';
132
+ if (AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(output))) return 'auth';
133
+ return 'transport';
134
+ }
135
+
136
+ function parseLsRemote(stdout) {
137
+ const refs = new Map();
138
+ for (const line of `${stdout ?? ''}`.trim().split('\n').filter(Boolean)) {
139
+ const tabIndex = line.indexOf('\t');
140
+ if (tabIndex < 0) continue;
141
+ const sha = line.slice(0, tabIndex);
142
+ const ref = line.slice(tabIndex + 1);
143
+ if (!SHA_RE.test(sha)) continue;
144
+ if (ref.endsWith('^{}')) {
145
+ refs.set(ref.slice(0, -3), sha);
146
+ } else if (!refs.has(ref)) {
147
+ refs.set(ref, sha);
148
+ }
149
+ }
150
+ return refs;
151
+ }
152
+
153
+ /**
154
+ * Cross-check that a downstream branch tip equals the pushed commit
155
+ * (review N-3). Fails closed on mismatch or a missing ref.
156
+ *
157
+ * @param {Function} exec - Git exec (argument arrays only).
158
+ * @param {string} remoteUrl
159
+ * @param {string} branch
160
+ * @param {string} pushedCommit - Full 40-hex commit sha.
161
+ * @returns {Promise<string>} The observed branch tip.
162
+ */
163
+ export async function crossCheckBranchTip(exec, remoteUrl, branch, pushedCommit) {
164
+ assertSafeRemoteUrl(remoteUrl);
165
+ assertSafeBranch(branch);
166
+ if (typeof pushedCommit !== 'string' || !SHA_RE.test(pushedCommit)) {
167
+ throw new ReleaseError(GATE_FAILED, 'cross-check requires the full 40-hex pushed commit', { pushedCommit });
168
+ }
169
+ const execFn = typeof exec === 'function' ? exec : defaultExec;
170
+ const { stdout } = await execFn('git', ['ls-remote', remoteUrl, `refs/heads/${branch}`], {
171
+ env: netEnv(),
172
+ timeout: PROBE_TIMEOUT_MS,
173
+ shell: false,
174
+ });
175
+ const observed = parseLsRemote(stdout).get(`refs/heads/${branch}`) ?? null;
176
+ if (observed !== pushedCommit) {
177
+ throw new ReleaseError(
178
+ POST_PUBLISH_VERIFY_FAILED,
179
+ `post-push cross-check failed: downstream branch ${branch} is at ${observed ?? '<missing>'}, which disagrees with the pushed commit ${pushedCommit}`,
180
+ { remoteUrl, branch, pushedCommit, observed },
181
+ );
182
+ }
183
+ return observed;
184
+ }
185
+
186
+ /**
187
+ * Apply one preset mutation to a downstream git repository and push it.
188
+ *
189
+ * Lifecycle: resolve worktree (clone or workspace) -> checkout/verify branch
190
+ * -> `mutate(worktree)` -> stage + NO_CHANGE detection -> run `gates` ->
191
+ * commit (frozen identity) -> push (never --force) -> ls-remote cross-check.
192
+ *
193
+ * @param {object} params
194
+ * @param {object} params.target - { remoteUrl? XOR workspace?, branch }.
195
+ * @param {object} params.commitIdentity - Frozen { name, email }.
196
+ * @param {string} params.commitSubject - Deterministic commit subject.
197
+ * @param {(worktree: string) => Promise<void>} params.mutate - Writes the
198
+ * preset's files into the worktree.
199
+ * @param {object[]} [params.gates] - Downstream gates (argument arrays).
200
+ * @param {object} [params.contextProjection] - Injected into gate env.
201
+ * @param {string} params.root - Release workspace root (workspace addressing).
202
+ * @param {Function} [params.exec] - Injectable git exec (tests).
203
+ * @param {Function} [params.hookRunner] - Injectable gate runner (tests).
204
+ * @returns {Promise<{ status: 'EXECUTED'|'NO_CHANGE', observation: object }>}
205
+ * The observation carries `workspaceRealpath` for workspace addressing
206
+ * (§2.6 evidence, R4 review m-2) and `crossCheck: { status: 'skipped',
207
+ * reason }` when the post-push ls-remote cross-check could not run
208
+ * (R4 review m-4; a performed cross-check leaves no note).
209
+ */
210
+ export async function applyDownstreamGitChange(params) {
211
+ const {
212
+ target,
213
+ commitIdentity,
214
+ commitSubject,
215
+ mutate,
216
+ gates = [],
217
+ contextProjection,
218
+ root,
219
+ exec: execOpt,
220
+ hookRunner,
221
+ } = params ?? {};
222
+ if (!target || typeof target !== 'object') {
223
+ throw new ReleaseError(GATE_FAILED, 'preset downstream write requires a config.target');
224
+ }
225
+ assertSafeBranch(target.branch);
226
+ assertCommitIdentity(commitIdentity);
227
+ if (typeof mutate !== 'function') {
228
+ throw new ReleaseError(GATE_FAILED, 'preset downstream write requires a mutate function');
229
+ }
230
+ const exec = typeof execOpt === 'function' ? execOpt : defaultExec;
231
+ const runGate = typeof hookRunner === 'function' ? hookRunner : defaultRunGate;
232
+
233
+ const hasRemoteUrl = typeof target.remoteUrl === 'string';
234
+ const hasWorkspace = typeof target.workspace === 'string';
235
+ if (hasRemoteUrl === hasWorkspace) {
236
+ throw new ReleaseError(GATE_FAILED, 'preset downstream target must declare exactly one of remoteUrl or workspace');
237
+ }
238
+
239
+ let worktree = null;
240
+ let isClone = false;
241
+ let previousHead = null;
242
+ // §2.6 execution realpath (workspace addressing only): returned in the
243
+ // observation so the saga evidence records WHERE the write landed
244
+ // (R4 review m-2). Clone addressing uses a fresh tmpdir with no meaning
245
+ // after cleanup, so it stays null there.
246
+ let workspaceRealpath = null;
247
+
248
+ if (hasRemoteUrl) {
249
+ assertSafeRemoteUrl(target.remoteUrl);
250
+ worktree = await mkdtemp(join(tmpdir(), TMP_PREFIX));
251
+ isClone = true;
252
+ try {
253
+ await exec('git', ['clone', '--quiet', target.remoteUrl, worktree], {
254
+ env: netEnv(),
255
+ timeout: TRANSFER_TIMEOUT_MS,
256
+ shell: false,
257
+ });
258
+ } catch (error) {
259
+ await rm(worktree, { recursive: true, force: true }).catch(() => {});
260
+ const classification = classifyNetFailure(stderrText(error));
261
+ throw new ReleaseError(
262
+ classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE,
263
+ 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) },
267
+ );
268
+ }
269
+ // Continue the declared branch when it exists; start a root commit otherwise.
270
+ try {
271
+ const { stdout } = await exec('git', ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${target.branch}`], { cwd: worktree, shell: false });
272
+ previousHead = stdout.trim() || null;
273
+ } catch {
274
+ previousHead = null;
275
+ }
276
+ if (previousHead) {
277
+ await exec('git', ['checkout', '--quiet', '-B', target.branch, previousHead], { cwd: worktree, shell: false });
278
+ } else {
279
+ await exec('git', ['checkout', '--quiet', '--orphan', target.branch], { cwd: worktree, shell: false });
280
+ }
281
+ } else {
282
+ // Workspace addressing: §2.6 execution checks + a branch guard. The
283
+ // checkout is used in place (no clone); the branch is never switched.
284
+ const preflight = await preflightPresetWorkspace(target.workspace, { root });
285
+ const execution = await assertPresetWorkspaceExecution(target.workspace, {
286
+ root,
287
+ preflightRealpath: preflight.realpath,
288
+ });
289
+ worktree = execution.realpath;
290
+ workspaceRealpath = execution.realpath;
291
+ let headRef = '';
292
+ try {
293
+ const { stdout } = await exec('git', ['symbolic-ref', '--quiet', 'HEAD'], { cwd: worktree, shell: false });
294
+ headRef = `${stdout}`.trim();
295
+ } catch {
296
+ headRef = '';
297
+ }
298
+ if (headRef !== `refs/heads/${target.branch}`) {
299
+ throw new ReleaseError(
300
+ GATE_FAILED,
301
+ `preset downstream workspace must be checked out on branch "${target.branch}", but HEAD is at "${headRef || '<detached>'}"; check out the branch and rerun`,
302
+ { workspace: worktree, branch: target.branch, headRef: headRef || null },
303
+ );
304
+ }
305
+ try {
306
+ const { stdout } = await exec('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: worktree, shell: false });
307
+ previousHead = stdout.trim() || null;
308
+ } catch {
309
+ previousHead = null;
310
+ }
311
+ // Clean-tree requirement: the preset stages with `git add -A`, so a dirty
312
+ // checkout would sweep unrelated user work into the downstream commit.
313
+ // Fail closed for human decision instead.
314
+ const { stdout: dirtyOut } = await exec('git', ['status', '--porcelain'], { cwd: worktree, shell: false });
315
+ if (`${dirtyOut}`.trim().length > 0) {
316
+ throw new ReleaseError(
317
+ GATE_FAILED,
318
+ 'preset downstream workspace has uncommitted changes; the preset would have to stage them alongside its own write — commit or stash the local work and rerun',
319
+ { workspace: worktree },
320
+ );
321
+ }
322
+ }
323
+
324
+ try {
325
+ const git = (args, options = {}) => exec('git', args, { cwd: worktree, shell: false, timeout: GIT_TIMEOUT_MS, ...options });
326
+
327
+ // Preset mutation.
328
+ await mutate(worktree);
329
+
330
+ // Stage and detect NO_CHANGE against the branch tip.
331
+ await git(['add', '-A']);
332
+ const noChange = await git(['status', '--porcelain'])
333
+ .then(({ stdout }) => stdout.trim().length === 0)
334
+ .catch(() => false);
335
+ if (noChange) {
336
+ return {
337
+ status: 'NO_CHANGE',
338
+ observation: {
339
+ mode: 'no-change',
340
+ previousHead,
341
+ branchTip: previousHead,
342
+ ...(workspaceRealpath ? { workspaceRealpath } : {}),
343
+ },
344
+ };
345
+ }
346
+
347
+ // Downstream gates run BEFORE any commit/push; a failure leaves zero
348
+ // remote side effects (the staged change is discarded with the clone or
349
+ // reset in the workspace).
350
+ for (const [index, gate] of gates.entries()) {
351
+ let gateResult;
352
+ try {
353
+ gateResult = await runGate(
354
+ {
355
+ command: gate.command,
356
+ ...(gate.cwd ? { cwd: gate.cwd } : {}),
357
+ ...(gate.timeoutMs !== undefined ? { timeoutMs: gate.timeoutMs } : {}),
358
+ ...(gate.envAllowlist ? { envAllowlist: gate.envAllowlist } : {}),
359
+ },
360
+ {
361
+ root: worktree,
362
+ env: process.env,
363
+ injectEnv: contextProjection !== undefined
364
+ ? { [POSTPUBLISH_CONTEXT_ENV]: JSON.stringify(contextProjection) }
365
+ : {},
366
+ },
367
+ );
368
+ } catch (err) {
369
+ // Reset the staged change so a workspace is left clean on failure.
370
+ await git(['reset', '--quiet']).catch(() => {});
371
+ const code = err?.code === 'HOOK_TIMEOUT' ? 'HOOK_TIMEOUT' : GATE_FAILED;
372
+ throw new ReleaseError(
373
+ code === 'HOOK_TIMEOUT' ? GATE_FAILED : code,
374
+ `downstream gate ${index + 1} timed out or could not run: ${err?.message ?? err}`,
375
+ { gateIndex: index },
376
+ );
377
+ }
378
+ if (gateResult.exitCode !== 0) {
379
+ await git(['reset', '--quiet']).catch(() => {});
380
+ throw new ReleaseError(
381
+ GATE_FAILED,
382
+ `downstream gate ${index + 1} failed with exit code ${gateResult.exitCode}; the downstream change was not committed or pushed`,
383
+ { gateIndex: index, exitCode: gateResult.exitCode },
384
+ );
385
+ }
386
+ }
387
+
388
+ // Commit with the frozen bot identity, then push (NEVER --force).
389
+ await git([
390
+ '-c', `user.name=${commitIdentity.name}`,
391
+ '-c', `user.email=${commitIdentity.email}`,
392
+ 'commit', '--quiet', '-m', commitSubject,
393
+ ]);
394
+ const { stdout: headOut } = await git(['rev-parse', 'HEAD']);
395
+ const localCommit = headOut.trim();
396
+
397
+ try {
398
+ await exec('git', ['push', '--quiet', 'origin', target.branch], {
399
+ cwd: worktree,
400
+ env: netEnv(),
401
+ timeout: TRANSFER_TIMEOUT_MS,
402
+ shell: false,
403
+ });
404
+ } catch (error) {
405
+ const classification = classifyNetFailure(stderrText(error));
406
+ throw new ReleaseError(
407
+ classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE,
408
+ 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`,
411
+ { branch: target.branch, stderrTail: stderrTail(error) },
412
+ );
413
+ }
414
+
415
+ // Cross-check the pushed branch tip (review N-3). Workspace addressing
416
+ // resolves the checkout's origin URL; clone addressing uses the target.
417
+ // A skip is NEVER silent (R4 review m-4): an unresolvable or
418
+ // non-http(s)/file origin (SSH etc.) records crossCheck skipped + reason
419
+ // in the observation, so the evidence shows the verification gap.
420
+ let crossCheckUrl = target.remoteUrl;
421
+ let crossCheckSkipReason = null;
422
+ if (!hasRemoteUrl) {
423
+ try {
424
+ const { stdout } = await git(['remote', 'get-url', 'origin']);
425
+ crossCheckUrl = stdout.trim();
426
+ } catch {
427
+ crossCheckUrl = null;
428
+ }
429
+ if (!crossCheckUrl) {
430
+ crossCheckSkipReason =
431
+ '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)) {
433
+ 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';
435
+ }
436
+ }
437
+ if (crossCheckUrl && SAFE_REMOTE_URL_RE.test(crossCheckUrl)) {
438
+ await crossCheckBranchTip(exec, crossCheckUrl, target.branch, localCommit);
439
+ }
440
+
441
+ return {
442
+ status: 'EXECUTED',
443
+ observation: {
444
+ mode: 'pushed',
445
+ pushedCommit: localCommit,
446
+ previousHead,
447
+ branchTip: localCommit,
448
+ ...(workspaceRealpath ? { workspaceRealpath } : {}),
449
+ ...(crossCheckSkipReason ? { crossCheck: { status: 'skipped', reason: crossCheckSkipReason } } : {}),
450
+ },
451
+ };
452
+ } finally {
453
+ if (isClone) {
454
+ await rm(worktree, { recursive: true, force: true }).catch(() => {});
455
+ }
456
+ }
457
+ }
458
+
459
+ /** Default gate runner: delegates to the R1 hook runner (core/hooks.mjs). */
460
+ async function defaultRunGate(gate, context) {
461
+ const { runHook } = await import('./hooks.mjs');
462
+ return runHook(gate, context);
463
+ }