release-skill 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) 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 +31 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +13 -21
  10. package/README.zh-CN.md +14 -22
  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/registry.json +15 -1
  16. package/adapters/claude/bin/release-skill.bundle.mjs +38147 -23284
  17. package/adapters/claude/bin/rules.json +1 -1
  18. package/adapters/claude/schemas/.render-manifest.json +6 -6
  19. package/adapters/claude/schemas/release-plan.schema.json +401 -2
  20. package/adapters/claude/schemas/release-project.schema.json +291 -0
  21. package/adapters/claude/schemas/release-run.schema.json +59 -6
  22. package/adapters/claude/skills/release-config/SKILL.md +140 -0
  23. package/adapters/claude/skills/release-docs/SKILL.md +106 -0
  24. package/adapters/claude/skills/release-help/SKILL.md +39 -0
  25. package/adapters/claude/skills/release-marketplace/SKILL.md +147 -0
  26. package/adapters/claude/skills/release-publish/SKILL.md +25 -6
  27. package/adapters/claude/skills/release-verify/SKILL.md +10 -0
  28. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  29. package/adapters/codex/bin/error-codes.json +1 -1
  30. package/adapters/codex/bin/kernel-protocol.json +1 -1
  31. package/adapters/codex/bin/registry.json +15 -1
  32. package/adapters/codex/bin/release-skill.bundle.mjs +38147 -23284
  33. package/adapters/codex/bin/rules.json +1 -1
  34. package/adapters/codex/schemas/.render-manifest.json +6 -6
  35. package/adapters/codex/schemas/release-plan.schema.json +401 -2
  36. package/adapters/codex/schemas/release-project.schema.json +291 -0
  37. package/adapters/codex/schemas/release-run.schema.json +59 -6
  38. package/adapters/codex/skills/release-config/SKILL.md +147 -0
  39. package/adapters/codex/skills/release-docs/SKILL.md +113 -0
  40. package/adapters/codex/skills/release-help/SKILL.md +39 -0
  41. package/adapters/codex/skills/release-marketplace/SKILL.md +154 -0
  42. package/adapters/codex/skills/release-publish/SKILL.md +25 -6
  43. package/adapters/codex/skills/release-verify/SKILL.md +10 -0
  44. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  45. package/adapters/kimi/bin/error-codes.json +1 -1
  46. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  47. package/adapters/kimi/bin/registry.json +15 -1
  48. package/adapters/kimi/bin/release-skill.bundle.mjs +38147 -23284
  49. package/adapters/kimi/bin/rules.json +1 -1
  50. package/adapters/kimi/schemas/.render-manifest.json +6 -6
  51. package/adapters/kimi/schemas/release-plan.schema.json +401 -2
  52. package/adapters/kimi/schemas/release-project.schema.json +291 -0
  53. package/adapters/kimi/schemas/release-run.schema.json +59 -6
  54. package/adapters/kimi/skills/release-config/SKILL.md +147 -0
  55. package/adapters/kimi/skills/release-docs/SKILL.md +113 -0
  56. package/adapters/kimi/skills/release-help/SKILL.md +39 -0
  57. package/adapters/kimi/skills/release-marketplace/SKILL.md +154 -0
  58. package/adapters/kimi/skills/release-publish/SKILL.md +25 -6
  59. package/adapters/kimi/skills/release-verify/SKILL.md +10 -0
  60. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  61. package/adapters/workbuddy/bin/error-codes.json +1 -1
  62. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  63. package/adapters/workbuddy/bin/registry.json +15 -1
  64. package/adapters/workbuddy/bin/release-skill.bundle.mjs +38147 -23284
  65. package/adapters/workbuddy/bin/rules.json +1 -1
  66. package/adapters/workbuddy/schemas/.render-manifest.json +6 -6
  67. package/adapters/workbuddy/schemas/release-plan.schema.json +401 -2
  68. package/adapters/workbuddy/schemas/release-project.schema.json +291 -0
  69. package/adapters/workbuddy/schemas/release-run.schema.json +59 -6
  70. package/adapters/workbuddy/skills/release-config/SKILL.md +140 -0
  71. package/adapters/workbuddy/skills/release-docs/SKILL.md +106 -0
  72. package/adapters/workbuddy/skills/release-help/SKILL.md +39 -0
  73. package/adapters/workbuddy/skills/release-marketplace/SKILL.md +147 -0
  74. package/adapters/workbuddy/skills/release-publish/SKILL.md +25 -6
  75. package/adapters/workbuddy/skills/release-verify/SKILL.md +10 -0
  76. package/bin/error-codes.json +1 -1
  77. package/bin/kernel-protocol.json +1 -1
  78. package/bin/registry.json +15 -1
  79. package/bin/release-skill-cli.mjs +235 -7
  80. package/bin/release-skill.bundle.mjs +38147 -23284
  81. package/bin/rules.json +1 -1
  82. package/package.json +5 -4
  83. package/platform-manifest.json +359 -0
  84. package/references/.render-manifest.json +9 -9
  85. package/references/02-project-config.md +35 -0
  86. package/references/05-evidence-and-errors.md +44 -0
  87. package/references/06-adapter-contract.md +13 -0
  88. package/schemas/.render-manifest.json +6 -6
  89. package/schemas/release-plan.schema.json +401 -2
  90. package/schemas/release-project.schema.json +291 -0
  91. package/schemas/release-run.schema.json +59 -6
  92. package/skills/release-config/SKILL.md +140 -0
  93. package/skills/release-docs/SKILL.md +106 -0
  94. package/skills/release-help/SKILL.md +39 -0
  95. package/skills/release-marketplace/SKILL.md +147 -0
  96. package/skills/release-publish/SKILL.md +25 -6
  97. package/skills/release-verify/SKILL.md +10 -0
  98. package/skills-src/release-config/SKILL.md +140 -0
  99. package/skills-src/release-docs/SKILL.md +106 -0
  100. package/skills-src/release-help/SKILL.md +39 -0
  101. package/skills-src/release-marketplace/SKILL.md +147 -0
  102. package/skills-src/release-publish/SKILL.md +25 -6
  103. package/skills-src/release-verify/SKILL.md +10 -0
  104. package/src/adapters/contract.mjs +5 -0
  105. package/src/adapters/distribute-git.mjs +625 -0
  106. package/src/adapters/plugin-marketplace.mjs +48 -4
  107. package/src/adapters/push-snapshot.mjs +12 -4
  108. package/src/commands/assess.mjs +171 -0
  109. package/src/commands/distribute.mjs +1078 -0
  110. package/src/commands/hooks.mjs +6 -1
  111. package/src/commands/lineage.mjs +616 -0
  112. package/src/commands/prepare.mjs +413 -72
  113. package/src/commands/route.mjs +686 -0
  114. package/src/commands/ship.mjs +45 -1
  115. package/src/commands/verify.mjs +176 -0
  116. package/src/core/baseline-advance.mjs +185 -0
  117. package/src/core/baseline.mjs +11 -1
  118. package/src/core/checkpoints.mjs +25 -0
  119. package/src/core/foundation-inflight.mjs +7 -0
  120. package/src/core/plan.mjs +12 -2
  121. package/src/core/postpublish.mjs +315 -0
@@ -0,0 +1,625 @@
1
+ /**
2
+ * Post-publish distribution adapter: append-only git mirrors (R1–R4).
3
+ *
4
+ * After publish pushes the frozen tag, `distribute` mirrors the payload to
5
+ * arbitrary consumer-declared git remotes (any git host, plain http
6
+ * included). This adapter implements the consumer semantics:
7
+ *
8
+ * - probe (DISTRIBUTE_PROBE, built-in R3 obligation): a single
9
+ * `git ls-remote <remoteUrl> HEAD` with GIT_TERMINAL_PROMPT=0 and a ~30s
10
+ * timeout. It reuses whatever credential the host keychain already
11
+ * provides; distribute never reads, creates, prints, or retries
12
+ * credentials. Transport failure -> REMOTE_UNAVAILABLE with an actionable
13
+ * "start VPN / check network" message; auth refusal -> REMOTE_CONFLICT.
14
+ * A second ls-remote classifies existing same-name-tag state. No retries
15
+ * anywhere.
16
+ * - mirror (DISTRIBUTE_MIRROR): clone the existing target to continue its
17
+ * history, wipe everything but .git, write the payload (payload-mirror)
18
+ * or the rendered `.claude-plugin/marketplace.json` + staticFiles
19
+ * (marketplace-index), stage, and:
20
+ * - staged tree identical to the branch tip -> NO_CHANGE, reported
21
+ * honestly: no commit, no tag, no push (idempotent repeat);
22
+ * - existing same-name tag + changed content -> REMOTE_CONFLICT
23
+ * (moving an existing tag would be force-equivalent);
24
+ * - otherwise commit with the frozen bot identity
25
+ * (`git -c user.name=… -c user.email=…`, never touching global git
26
+ * config), tag with the source tag name, plain push branch + tag.
27
+ * NEVER --force.
28
+ * Dry-run stops after the local commit + tag: zero remote side effects
29
+ * and no sha (R4: the marketplace sha is backfilled from the actual push
30
+ * result only).
31
+ *
32
+ * Payload materialization is deliberately NOT part of this adapter: the
33
+ * distribute command runs the consumer-declared materialize hook in the
34
+ * detached tag worktree and hands the adapter a payload directory. This
35
+ * module never reimplements consumer payload logic in parallel.
36
+ *
37
+ * @module adapters/distribute-git
38
+ */
39
+
40
+ import { execFile as execFileCb } from 'node:child_process';
41
+ import { promisify } from 'node:util';
42
+ import { cp, copyFile, mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises';
43
+ import { tmpdir } from 'node:os';
44
+ import { dirname, isAbsolute, join } from 'node:path';
45
+
46
+ import {
47
+ ActionStatus,
48
+ ActionType,
49
+ assertWritesAuthorized,
50
+ createResult,
51
+ } from './contract.mjs';
52
+ import { ReleaseError, GATE_FAILED, REMOTE_CONFLICT, REMOTE_UNAVAILABLE } from '../core/errors.mjs';
53
+
54
+ const execFile = promisify(execFileCb);
55
+
56
+ const NAME = 'distribute-git';
57
+ const PROBE_TIMEOUT_MS = 30_000;
58
+ const GIT_TIMEOUT_MS = 120_000;
59
+ const TRANSFER_TIMEOUT_MS = 300_000;
60
+ const TMP_PREFIX = 'release-skill-distribute-';
61
+
62
+ /** Remote URL pattern: http(s) or file (test transport), .git-suffixed. */
63
+ const SAFE_REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
64
+ /** Branch pattern (leading alphanumeric blocks option-like names). */
65
+ const SAFE_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
66
+ /** Safe tag characters (belt + braces; prepare already rendered the tag). */
67
+ const SAFE_TAG_RE = /^[^\s\x00-\x1f\x7f~^:?*[\]\\]+$/;
68
+ /** Safe target id. */
69
+ const SAFE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
70
+ /** Full 40-hex commit sha (R4: the sha source is the actual push result). */
71
+ const SHA_RE = /^[a-f0-9]{40}$/;
72
+
73
+ /**
74
+ * Credential hygiene (R3): git is never allowed to prompt. The host
75
+ * keychain either already has a credential or the probe fails fast.
76
+ */
77
+ const NEVER_PROMPT_ENV = { GIT_TERMINAL_PROMPT: '0' };
78
+
79
+ const AUTH_FAILURE_PATTERNS = [
80
+ /authentication failed/i,
81
+ /could not read username/i,
82
+ /could not read password/i,
83
+ /terminal prompts disabled/i,
84
+ /invalid username or password/i,
85
+ /permission denied/i,
86
+ /access denied/i,
87
+ /authorization failed/i,
88
+ /\b403\b/,
89
+ /\b401\b/,
90
+ ];
91
+
92
+ const TRANSPORT_FAILURE_PATTERNS = [
93
+ /could not resolve host/i,
94
+ /unable to access/i,
95
+ /connection refused/i,
96
+ /connection timed out/i,
97
+ /operation timed out/i,
98
+ /network is unreachable/i,
99
+ /ssl certificate problem/i,
100
+ /could not resolve proxy/i,
101
+ /failed to connect/i,
102
+ ];
103
+
104
+ /**
105
+ * Classify a failed reachability probe: 'auth' (the remote answered but the
106
+ * host keychain has no usable credential) or 'transport' (unreachable).
107
+ * Unknown failures fail closed as transport: the actionable outcome is the
108
+ * VPN/network message and distribute never retries with credentials.
109
+ *
110
+ * @param {string} [text] - Combined stderr/message text of the failure.
111
+ * @returns {'auth'|'transport'}
112
+ */
113
+ export function classifyProbeFailure(text) {
114
+ const output = typeof text === 'string' ? text : '';
115
+ if (AUTH_FAILURE_PATTERNS.some((pattern) => pattern.test(output))) return 'auth';
116
+ if (TRANSPORT_FAILURE_PATTERNS.some((pattern) => pattern.test(output))) return 'transport';
117
+ return 'transport';
118
+ }
119
+
120
+ /**
121
+ * Render the `.claude-plugin/marketplace.json` index for a
122
+ * marketplace-index target (R4 manifest forms):
123
+ * - github form: { source: "github", repo, ref, sha }
124
+ * - url form: { source: "url", url, ref, sha }
125
+ *
126
+ * `sha` must be the FULL 40-hex commit hash backfilled from the actual push
127
+ * result of the dependsOn payload-mirror target; pass `null` in dry-run and
128
+ * the key is omitted entirely (no push result exists yet).
129
+ *
130
+ * @param {object} marketplace - Frozen declaration marketplace block.
131
+ * @param {object} params
132
+ * @param {string} params.pluginName - Plugin name (resolved from the payload).
133
+ * @param {string} params.ref - Frozen distribution tag.
134
+ * @param {string|null} params.sha - Pushed mirror commit sha or null (dry-run).
135
+ * @param {string|null} params.dependencyUrl - payload-mirror remoteUrl (url form).
136
+ * @returns {object} The marketplace index document.
137
+ * @throws {ReleaseError} GATE_FAILED on any unsafe or missing input.
138
+ */
139
+ export function renderMarketplaceIndex(marketplace, params = {}) {
140
+ const fail = (message, details = {}) => {
141
+ throw new ReleaseError(GATE_FAILED, `marketplace index render failed: ${message}`, details);
142
+ };
143
+ if (!marketplace || typeof marketplace !== 'object' || Array.isArray(marketplace)) {
144
+ fail('marketplace metadata is required');
145
+ }
146
+ if (marketplace.form !== 'github' && marketplace.form !== 'url') {
147
+ fail('marketplace.form must be "github" or "url"', { form: marketplace.form });
148
+ }
149
+ const { pluginName, ref, sha = null, dependencyUrl = null } = params;
150
+ if (typeof marketplace.name !== 'string' || marketplace.name.length === 0) fail('marketplace.name must be a non-empty string');
151
+ if (typeof marketplace.owner !== 'string' || marketplace.owner.length === 0) fail('marketplace.owner must be a non-empty string');
152
+ if (typeof pluginName !== 'string' || pluginName.length === 0) fail('pluginName must be a non-empty string');
153
+ if (typeof ref !== 'string' || ref.length === 0) fail('ref must be a non-empty tag');
154
+ if (sha !== null && !SHA_RE.test(sha)) {
155
+ fail('sha must be the full 40-hex commit hash from the actual push result', { sha });
156
+ }
157
+
158
+ let source;
159
+ if (marketplace.form === 'github') {
160
+ source = {
161
+ source: 'github',
162
+ repo: marketplace.sourceRepo ?? `${marketplace.owner}/${marketplace.name}`,
163
+ ref,
164
+ ...(sha ? { sha } : {}),
165
+ };
166
+ } else {
167
+ if (typeof dependencyUrl !== 'string' || !SAFE_REMOTE_URL_RE.test(dependencyUrl)) {
168
+ fail('url form requires the payload-mirror dependency remoteUrl', { dependencyUrl });
169
+ }
170
+ source = {
171
+ source: 'url',
172
+ url: dependencyUrl,
173
+ ref,
174
+ ...(sha ? { sha } : {}),
175
+ };
176
+ }
177
+
178
+ return {
179
+ name: marketplace.name,
180
+ owner: { name: marketplace.owner },
181
+ plugins: [{ name: pluginName, source }],
182
+ };
183
+ }
184
+
185
+ function hasControlChars(value) {
186
+ return /[\x00-\x1f\x7f]/.test(value);
187
+ }
188
+
189
+ function assertSafeRemoteUrl(remoteUrl) {
190
+ if (typeof remoteUrl !== 'string' || !SAFE_REMOTE_URL_RE.test(remoteUrl) || hasControlChars(remoteUrl)) {
191
+ throw new ReleaseError(
192
+ GATE_FAILED,
193
+ 'distribute remoteUrl must be an http(s)/file URL ending in .git',
194
+ { remoteUrl },
195
+ );
196
+ }
197
+ }
198
+
199
+ function assertSafeBranch(branch) {
200
+ if (typeof branch !== 'string' || !SAFE_BRANCH_RE.test(branch)
201
+ || branch.includes('..') || branch.endsWith('.') || branch.endsWith('.lock')) {
202
+ throw new ReleaseError(GATE_FAILED, 'distribute branch is not a safe Git branch name', { branch });
203
+ }
204
+ }
205
+
206
+ function assertSafeTag(tag) {
207
+ if (typeof tag !== 'string' || tag.length === 0 || tag.startsWith('-') || !SAFE_TAG_RE.test(tag)) {
208
+ throw new ReleaseError(GATE_FAILED, 'distribute tag is not a safe Git tag name', { tag });
209
+ }
210
+ }
211
+
212
+ function assertCommitIdentity(identity) {
213
+ if (!identity || typeof identity !== 'object') {
214
+ throw new ReleaseError(GATE_FAILED, 'distribute requires a frozen commitIdentity');
215
+ }
216
+ for (const field of ['name', 'email']) {
217
+ const value = identity[field];
218
+ if (typeof value !== 'string' || value.length === 0 || value.startsWith('-') || hasControlChars(value)) {
219
+ throw new ReleaseError(GATE_FAILED, `distribute commitIdentity.${field} is missing or unsafe`, { field });
220
+ }
221
+ }
222
+ }
223
+
224
+ async function run(command, args, options = {}) {
225
+ return execFile(command, args, {
226
+ shell: false,
227
+ encoding: 'utf8',
228
+ timeout: GIT_TIMEOUT_MS,
229
+ ...options,
230
+ });
231
+ }
232
+
233
+ function stderrText(error) {
234
+ return `${error?.stderr ?? ''}\n${error?.message ?? ''}`;
235
+ }
236
+
237
+ function stderrTail(error, limit = 400) {
238
+ const text = stderrText(error).trim();
239
+ return text.length > limit ? `…${text.slice(-limit)}` : text;
240
+ }
241
+
242
+ /** Parse `git ls-remote` stdout lines ("<sha>\t<ref>") into sha per ref. */
243
+ function parseLsRemote(stdout) {
244
+ const refs = new Map();
245
+ for (const line of `${stdout ?? ''}`.trim().split('\n').filter(Boolean)) {
246
+ const tabIndex = line.indexOf('\t');
247
+ if (tabIndex < 0) continue;
248
+ const sha = line.slice(0, tabIndex);
249
+ const ref = line.slice(tabIndex + 1);
250
+ if (!SHA_RE.test(sha)) continue;
251
+ // Prefer the peeled ("ref^{}") commit for annotated tags.
252
+ if (ref.endsWith('^{}')) {
253
+ refs.set(ref.slice(0, -3), sha);
254
+ } else if (!refs.has(ref)) {
255
+ refs.set(ref, sha);
256
+ }
257
+ }
258
+ return refs;
259
+ }
260
+
261
+ async function countFiles(dir) {
262
+ let total = 0;
263
+ const entries = await readdir(dir, { withFileTypes: true });
264
+ for (const entry of entries) {
265
+ if (entry.name === '.git') continue;
266
+ const fullPath = join(dir, entry.name);
267
+ if (entry.isDirectory()) {
268
+ total += await countFiles(fullPath);
269
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
270
+ total += 1;
271
+ }
272
+ }
273
+ return total;
274
+ }
275
+
276
+ /**
277
+ * Create the distribute-git adapter.
278
+ *
279
+ * @param {object} [deps]
280
+ * @param {Function} [deps.exec] - Injectable exec (tests); defaults to execFile.
281
+ * @param {Function} [deps.mkdtempFn] - Injectable mkdtemp (tests).
282
+ * @param {Function} [deps.rmFn] - Injectable rm (tests).
283
+ * @param {string} [deps.tmpRoot] - Temp root for clone dirs (tests).
284
+ */
285
+ export function createDistributeGitAdapter(deps = {}) {
286
+ const exec = deps.exec ?? run;
287
+ const mkdtempFn = deps.mkdtempFn ?? (async (prefix) => mkdtemp(prefix));
288
+ const rmFn = deps.rmFn ?? (async (path, options) => rm(path, options));
289
+ const tmpRoot = deps.tmpRoot ?? tmpdir();
290
+
291
+ const netEnv = () => ({ ...process.env, ...NEVER_PROMPT_ENV });
292
+
293
+ async function lsRemoteRefs(remoteUrl, refs) {
294
+ const { stdout } = await exec('git', ['ls-remote', remoteUrl, ...refs], {
295
+ env: netEnv(),
296
+ timeout: PROBE_TIMEOUT_MS,
297
+ shell: false,
298
+ });
299
+ return parseLsRemote(stdout);
300
+ }
301
+
302
+ function probeFailureResult(action, error) {
303
+ const classification = classifyProbeFailure(stderrText(error));
304
+ const code = classification === 'auth' ? REMOTE_CONFLICT : REMOTE_UNAVAILABLE;
305
+ const message = classification === 'auth'
306
+ ? `remote refused authentication for ${action.remoteUrl}; distribute reuses the host keychain credential and never prompts, reads, or retries credentials`
307
+ : `cannot reach ${action.remoteUrl} — start VPN / check network; distribute fails closed and never retries with credentials`;
308
+ return createResult({
309
+ actionType: action.actionType,
310
+ status: ActionStatus.PREFLIGHT_FAILED,
311
+ error: message,
312
+ details: {
313
+ code,
314
+ classification,
315
+ remoteUrl: action.remoteUrl,
316
+ stderrTail: stderrTail(error),
317
+ },
318
+ });
319
+ }
320
+
321
+ async function probeRemote(action) {
322
+ assertSafeRemoteUrl(action.remoteUrl);
323
+ assertSafeTag(action.tag);
324
+ let headRefs;
325
+ try {
326
+ headRefs = await lsRemoteRefs(action.remoteUrl, ['HEAD']);
327
+ } catch (error) {
328
+ return probeFailureResult(action, error);
329
+ }
330
+ let tagRefs;
331
+ try {
332
+ tagRefs = await lsRemoteRefs(action.remoteUrl, [`refs/tags/${action.tag}`]);
333
+ } catch (error) {
334
+ return probeFailureResult(action, error);
335
+ }
336
+ const tagOid = tagRefs.get(`refs/tags/${action.tag}`) ?? null;
337
+ return createResult({
338
+ actionType: action.actionType,
339
+ status: ActionStatus.PREFLIGHT_PASSED,
340
+ observation: {
341
+ reachable: true,
342
+ head: headRefs.get('HEAD') ?? null,
343
+ tagOid,
344
+ tagExists: tagOid !== null,
345
+ },
346
+ });
347
+ }
348
+
349
+ function assertMirrorActionShape(action) {
350
+ if (typeof action.targetId !== 'string' || !SAFE_ID_RE.test(action.targetId)) {
351
+ throw new ReleaseError(GATE_FAILED, 'distribute-mirror requires a safe targetId', { targetId: action.targetId });
352
+ }
353
+ if (action.kind !== 'payload-mirror' && action.kind !== 'marketplace-index') {
354
+ throw new ReleaseError(GATE_FAILED, 'distribute-mirror kind must be payload-mirror or marketplace-index', { kind: action.kind });
355
+ }
356
+ assertSafeRemoteUrl(action.remoteUrl);
357
+ assertSafeBranch(action.branch);
358
+ assertSafeTag(action.tag);
359
+ assertCommitIdentity(action.commitIdentity);
360
+ if (action.kind === 'payload-mirror') {
361
+ if (typeof action.payloadDir !== 'string' || action.payloadDir.length === 0 || !isAbsolute(action.payloadDir)) {
362
+ throw new ReleaseError(GATE_FAILED, 'payload-mirror requires an absolute payloadDir', { payloadDir: action.payloadDir });
363
+ }
364
+ } else {
365
+ if (!action.marketplace || typeof action.marketplace !== 'object') {
366
+ throw new ReleaseError(GATE_FAILED, 'marketplace-index requires the frozen marketplace block');
367
+ }
368
+ if (typeof action.pluginName !== 'string' || action.pluginName.length === 0) {
369
+ throw new ReleaseError(GATE_FAILED, 'marketplace-index requires a resolved pluginName');
370
+ }
371
+ if (!action.dependency || typeof action.dependency !== 'object') {
372
+ throw new ReleaseError(GATE_FAILED, 'marketplace-index requires its payload-mirror dependency result');
373
+ }
374
+ assertSafeRemoteUrl(action.dependency.remoteUrl);
375
+ if (action.dependency.sha !== null && action.dependency.sha !== undefined && !SHA_RE.test(action.dependency.sha)) {
376
+ throw new ReleaseError(GATE_FAILED, 'dependency sha must be null (dry-run) or the full 40-hex push result', { sha: action.dependency.sha });
377
+ }
378
+ }
379
+ for (const [index, file] of (action.staticFiles ?? []).entries()) {
380
+ if (typeof file?.sourcePath !== 'string' || !isAbsolute(file.sourcePath)) {
381
+ throw new ReleaseError(GATE_FAILED, `staticFiles[${index}].sourcePath must be an absolute path`, { index });
382
+ }
383
+ if (typeof file?.to !== 'string' || file.to.length === 0
384
+ || file.to.startsWith('/') || file.to.startsWith('./') || file.to.includes('..')
385
+ || file.to.includes('\\') || file.to.includes(':')) {
386
+ throw new ReleaseError(GATE_FAILED, `staticFiles[${index}].to is not a safe relative path`, { index, to: file?.to });
387
+ }
388
+ }
389
+ }
390
+
391
+ async function mirror(action) {
392
+ assertMirrorActionShape(action);
393
+ const cloneDir = await mkdtempFn(join(tmpRoot, TMP_PREFIX));
394
+ const git = (args, options = {}) => exec('git', args, { cwd: cloneDir, shell: false, timeout: GIT_TIMEOUT_MS, ...options });
395
+ try {
396
+ // Clone the existing target to continue its history (append-only).
397
+ await exec('git', ['clone', '--quiet', action.remoteUrl, cloneDir], {
398
+ env: netEnv(),
399
+ timeout: TRANSFER_TIMEOUT_MS,
400
+ shell: false,
401
+ });
402
+
403
+ // Continue the declared branch when it exists; start a root commit otherwise.
404
+ let previousHead = null;
405
+ try {
406
+ const { stdout } = await git(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${action.branch}`]);
407
+ previousHead = stdout.trim() || null;
408
+ } catch {
409
+ previousHead = null;
410
+ }
411
+ if (previousHead) {
412
+ await git(['checkout', '--quiet', '-B', action.branch, previousHead]);
413
+ } else {
414
+ await git(['checkout', '--quiet', '--orphan', action.branch]);
415
+ }
416
+
417
+ // Wipe everything but .git, then write the declared content.
418
+ for (const entry of await readdir(cloneDir)) {
419
+ if (entry === '.git') continue;
420
+ await rm(join(cloneDir, entry), { recursive: true, force: true });
421
+ }
422
+ if (action.kind === 'payload-mirror') {
423
+ const payloadStat = await stat(action.payloadDir).catch(() => null);
424
+ if (!payloadStat || !payloadStat.isDirectory()) {
425
+ throw new ReleaseError(GATE_FAILED, 'payloadDir does not exist or is not a directory', { payloadDir: action.payloadDir });
426
+ }
427
+ await cp(action.payloadDir, cloneDir, { recursive: true });
428
+ } else {
429
+ const index = renderMarketplaceIndex(action.marketplace, {
430
+ pluginName: action.pluginName,
431
+ ref: action.tag,
432
+ sha: action.dependency.sha ?? null,
433
+ dependencyUrl: action.dependency.remoteUrl,
434
+ });
435
+ await mkdir(join(cloneDir, '.claude-plugin'), { recursive: true });
436
+ await writeFile(join(cloneDir, '.claude-plugin', 'marketplace.json'), `${JSON.stringify(index, null, 2)}\n`);
437
+ }
438
+ for (const file of action.staticFiles ?? []) {
439
+ await mkdir(dirname(join(cloneDir, file.to)), { recursive: true });
440
+ await copyFile(file.sourcePath, join(cloneDir, file.to));
441
+ }
442
+
443
+ const payloadFileCount = await countFiles(cloneDir);
444
+ await git(['add', '-A']);
445
+
446
+ // Honest NO_CHANGE: staged tree identical to the branch tip (or the
447
+ // empty tree on a root commit) -> no commit, no tag, no push.
448
+ const noChange = await git(['status', '--porcelain'])
449
+ .then(({ stdout }) => stdout.trim().length === 0)
450
+ .catch(() => false);
451
+ if (noChange) {
452
+ return createResult({
453
+ actionType: action.actionType,
454
+ status: ActionStatus.NO_CHANGE,
455
+ observation: { mode: 'no-change', previousHead, branchTip: previousHead, payloadFileCount },
456
+ });
457
+ }
458
+
459
+ // Same-name tag + changed content: moving an existing tag to a new
460
+ // commit would be force-equivalent. Fail closed for human decision.
461
+ let existingTag = '';
462
+ try {
463
+ const { stdout } = await git(['rev-parse', '--verify', '--quiet', `refs/tags/${action.tag}`]);
464
+ existingTag = stdout.trim();
465
+ } catch {
466
+ existingTag = '';
467
+ }
468
+ if (existingTag) {
469
+ throw new ReleaseError(
470
+ REMOTE_CONFLICT,
471
+ `target ${action.remoteUrl} already carries tag ${action.tag} and the content differs; moving an existing tag would be force-equivalent — human decision required`,
472
+ { targetId: action.targetId, tag: action.tag },
473
+ );
474
+ }
475
+
476
+ // Commit with the frozen bot identity, injected per-command so the
477
+ // global git config is never read or modified.
478
+ const commitMessage = `release-skill distribute ${action.tag} (${action.targetId})`;
479
+ await git([
480
+ '-c', `user.name=${action.commitIdentity.name}`,
481
+ '-c', `user.email=${action.commitIdentity.email}`,
482
+ 'commit', '--quiet', '-m', commitMessage,
483
+ ]);
484
+ const { stdout: headOut } = await git(['rev-parse', 'HEAD']);
485
+ const localCommit = headOut.trim();
486
+ await git(['tag', action.tag, localCommit]);
487
+
488
+ if (action.dryRun) {
489
+ return createResult({
490
+ actionType: action.actionType,
491
+ status: ActionStatus.EXECUTED,
492
+ observation: { mode: 'dry-run', localCommit, localTag: action.tag, previousHead, payloadFileCount },
493
+ });
494
+ }
495
+
496
+ // Plain push, branch first then tag. NEVER --force.
497
+ await exec('git', ['push', '--quiet', 'origin', action.branch], {
498
+ cwd: cloneDir,
499
+ env: netEnv(),
500
+ timeout: TRANSFER_TIMEOUT_MS,
501
+ shell: false,
502
+ });
503
+ await exec('git', ['push', '--quiet', 'origin', `refs/tags/${action.tag}`], {
504
+ cwd: cloneDir,
505
+ env: netEnv(),
506
+ timeout: TRANSFER_TIMEOUT_MS,
507
+ shell: false,
508
+ });
509
+
510
+ return createResult({
511
+ actionType: action.actionType,
512
+ status: ActionStatus.EXECUTED,
513
+ observation: {
514
+ mode: 'pushed',
515
+ pushedCommit: localCommit,
516
+ previousHead,
517
+ branchTip: localCommit,
518
+ tagOid: localCommit,
519
+ payloadFileCount,
520
+ },
521
+ });
522
+ } finally {
523
+ await rmFn(cloneDir, { recursive: true, force: true }).catch(() => {});
524
+ }
525
+ }
526
+
527
+ return Object.freeze({
528
+ name: NAME,
529
+ actionTypes: Object.freeze([ActionType.DISTRIBUTE_PROBE, ActionType.DISTRIBUTE_MIRROR]),
530
+
531
+ async preflight(action, context) {
532
+ try {
533
+ if (action.actionType === ActionType.DISTRIBUTE_PROBE) {
534
+ return await probeRemote(action);
535
+ }
536
+ if (action.actionType === ActionType.DISTRIBUTE_MIRROR) {
537
+ // Reachability is probed via dedicated DISTRIBUTE_PROBE actions;
538
+ // mirror preflight is a pure shape check (no network I/O).
539
+ assertMirrorActionShape(action);
540
+ return createResult({ actionType: action.actionType, status: ActionStatus.PREFLIGHT_PASSED });
541
+ }
542
+ throw new Error(`unsupported action type: ${action.actionType}`);
543
+ } catch (error) {
544
+ return createResult({
545
+ actionType: action.actionType,
546
+ status: ActionStatus.PREFLIGHT_FAILED,
547
+ error: error.message,
548
+ details: error instanceof ReleaseError ? { code: error.code } : null,
549
+ });
550
+ }
551
+ },
552
+
553
+ async execute(action, context) {
554
+ assertWritesAuthorized(context, action.actionType);
555
+ if (action.actionType !== ActionType.DISTRIBUTE_MIRROR) {
556
+ return createResult({
557
+ actionType: action.actionType,
558
+ status: ActionStatus.EXECUTE_FAILED,
559
+ error: `unsupported action type: ${action.actionType}`,
560
+ });
561
+ }
562
+ try {
563
+ return await mirror(action);
564
+ } catch (error) {
565
+ return createResult({
566
+ actionType: action.actionType,
567
+ status: ActionStatus.EXECUTE_FAILED,
568
+ error: error.message,
569
+ details: { code: error instanceof ReleaseError ? error.code : GATE_FAILED, stderrTail: stderrTail(error) },
570
+ });
571
+ }
572
+ },
573
+
574
+ async observe(action, context) {
575
+ try {
576
+ assertSafeRemoteUrl(action.remoteUrl);
577
+ assertSafeBranch(action.branch);
578
+ assertSafeTag(action.tag);
579
+ const refs = await lsRemoteRefs(action.remoteUrl, [`refs/heads/${action.branch}`, `refs/tags/${action.tag}`]);
580
+ const observation = {
581
+ branchTip: refs.get(`refs/heads/${action.branch}`) ?? null,
582
+ tagOid: refs.get(`refs/tags/${action.tag}`) ?? null,
583
+ };
584
+ if (action.pushedCommit) {
585
+ observation.pushedCommit = action.pushedCommit;
586
+ observation.consistent = observation.branchTip === action.pushedCommit
587
+ && observation.tagOid === action.pushedCommit;
588
+ }
589
+ return createResult({ actionType: action.actionType, status: ActionStatus.OBSERVED, observation });
590
+ } catch (error) {
591
+ return createResult({
592
+ actionType: action.actionType,
593
+ status: ActionStatus.OBSERVED,
594
+ observation: {},
595
+ error: error.message,
596
+ });
597
+ }
598
+ },
599
+
600
+ async verify(action, context) {
601
+ const observed = await this.observe(action, context);
602
+ if (observed.error) {
603
+ return createResult({
604
+ actionType: action.actionType,
605
+ status: ActionStatus.VERIFY_FAILED,
606
+ observation: observed.observation,
607
+ error: observed.error,
608
+ });
609
+ }
610
+ if (!action.pushedCommit) {
611
+ // Nothing was pushed (NO_CHANGE / dry-run): remote state was reported as-is.
612
+ return createResult({ actionType: action.actionType, status: ActionStatus.VERIFIED, observation: observed.observation });
613
+ }
614
+ if (observed.observation.consistent === true) {
615
+ return createResult({ actionType: action.actionType, status: ActionStatus.VERIFIED, observation: observed.observation });
616
+ }
617
+ return createResult({
618
+ actionType: action.actionType,
619
+ status: ActionStatus.VERIFY_FAILED,
620
+ observation: observed.observation,
621
+ error: `remote refs disagree with the pushed commit ${action.pushedCommit}`,
622
+ });
623
+ },
624
+ });
625
+ }