release-skill 0.6.1 → 0.6.2

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 (31) 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 +17 -0
  7. package/INSTALL.md +2 -2
  8. package/INSTALL.zh-CN.md +2 -2
  9. package/README.md +12 -9
  10. package/README.zh-CN.md +12 -9
  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/release-skill.bundle.mjs +326 -70
  14. package/adapters/claude/schemas/release-plan.schema.json +9 -0
  15. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  16. package/adapters/codex/bin/release-skill.bundle.mjs +326 -70
  17. package/adapters/codex/schemas/release-plan.schema.json +9 -0
  18. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  19. package/adapters/kimi/bin/release-skill.bundle.mjs +326 -70
  20. package/adapters/kimi/schemas/release-plan.schema.json +9 -0
  21. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  22. package/adapters/workbuddy/bin/release-skill.bundle.mjs +326 -70
  23. package/adapters/workbuddy/schemas/release-plan.schema.json +9 -0
  24. package/bin/release-skill.bundle.mjs +326 -70
  25. package/package.json +1 -1
  26. package/schemas/release-plan.schema.json +9 -0
  27. package/src/commands/lineage.mjs +101 -32
  28. package/src/commands/prepare.mjs +59 -1
  29. package/src/commands/publish.mjs +10 -1
  30. package/src/core/skill-resource-closure.mjs +240 -10
  31. package/src/platforms/registry.mjs +12 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "release-skill",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Safe preparation and frozen GitHub/npm production publishing with full happy end verification",
5
5
  "author": {
6
6
  "name": "广州市风荷科技有限公司"
@@ -863,6 +863,13 @@
863
863
  "findingCount": {
864
864
  "const": 0
865
865
  },
866
+ "preparedAt": {
867
+ "type": ["string", "null"],
868
+ "description": "Deterministic freeze timestamp of the prepare run that produced this receipt (production: the baseline HEAD commit committer date in canonical UTC form; non-production: null). Never a wall-clock sample, so identical sources freeze byte-identical receipts."
869
+ },
870
+ "exitCode": {
871
+ "const": 0
872
+ },
866
873
  "receiptDigest": {
867
874
  "type": "string",
868
875
  "pattern": "^[a-f0-9]{64}$"
@@ -877,6 +884,8 @@
877
884
  "referenceCount",
878
885
  "sourceOnlyCount",
879
886
  "findingCount",
887
+ "preparedAt",
888
+ "exitCode",
880
889
  "receiptDigest"
881
890
  ]
882
891
  }
@@ -27,7 +27,7 @@
27
27
  * @module commands/lineage
28
28
  */
29
29
 
30
- import { execFile as execFileCb } from 'node:child_process';
30
+ import { execFile as execFileCb, spawn } from 'node:child_process';
31
31
  import { promisify } from 'node:util';
32
32
 
33
33
  const execFile = promisify(execFileCb);
@@ -132,6 +132,50 @@ async function git(root, args, options = {}) {
132
132
  return stdout.trim();
133
133
  }
134
134
 
135
+ /**
136
+ * Run a git command and return RAW stdout (no trimming). Trailing bytes are
137
+ * significant for `cat-file commit` output: the message tail (trailing
138
+ * whitespace / blank lines) must survive untouched.
139
+ */
140
+ async function gitRaw(root, args, options = {}) {
141
+ const { stdout } = await execFile('git', args, {
142
+ cwd: root,
143
+ encoding: 'utf8',
144
+ maxBuffer: 64 * 1024 * 1024,
145
+ ...options,
146
+ });
147
+ return stdout;
148
+ }
149
+
150
+ /**
151
+ * Parse a commit ident line — the value of an `author`/`committer` header in
152
+ * the raw commit object — of the canonical form `Name <email> ts tz`.
153
+ *
154
+ * The anchored regex uses a greedy `.*` so the LAST ` <email> ts tz` group at
155
+ * the end of the line wins: names containing spaces (or even '<'/'>' — legal
156
+ * in stored objects) still resolve correctly. This replaces the broken
157
+ * `split(/\s+>/)` parsing, which never matched a legal ident line (there is
158
+ * no whitespace before '>') and silently produced name=<entire line>,
159
+ * email='' — corrupting identities written by `commit-tree` during rebuild
160
+ * (SFA field report 2026-08-18).
161
+ *
162
+ * @param {string} line - e.g. `乌龙 <2505468+mzdbxqh@users.noreply.github.com> 1785824474 +0000`
163
+ * @returns {{ name: string, email: string, ts: string, tz: string }}
164
+ * @throws {Error} when the line is not a valid ident line. Fail-closed:
165
+ * rebuild must never fabricate or silently write an empty identity.
166
+ */
167
+ export function parseCommitIdent(line) {
168
+ if (typeof line !== 'string') {
169
+ throw new Error(`commit ident line must be a string, got ${typeof line}`);
170
+ }
171
+ const match = line.match(/^(.*) <([^>]*)> (\d+) ([+-]\d{4})$/);
172
+ if (!match) {
173
+ throw new Error(`unparseable commit ident line (fail-closed): "${line}"`);
174
+ }
175
+ const [, name, email, ts, tz] = match;
176
+ return { name, email, ts, tz };
177
+ }
178
+
135
179
  /**
136
180
  * List all tags with their commit SHAs.
137
181
  *
@@ -201,45 +245,44 @@ export async function objectExists(root, sha) {
201
245
  /**
202
246
  * Read the raw commit object and extract identity/date fields.
203
247
  *
248
+ * The cat-file output is NOT trimmed: the header block and the message are
249
+ * split at the first blank line and the message tail bytes (trailing
250
+ * whitespace / blank lines) are preserved exactly, so a rebuilt commit can
251
+ * carry byte-identical message bytes.
252
+ *
204
253
  * @param {string} root - repository root
205
254
  * @param {string} commitSha - commit SHA
206
255
  * @returns {Promise<{ message: string, author: { name: string, email: string, date: string }, committer: { name: string, email: string, date: string } }>}
256
+ * @throws {Error} when the object is malformed or an ident line does not
257
+ * parse (fail-closed — never fabricate or silently write empty identity).
207
258
  */
208
259
  export async function readCommitMeta(root, commitSha) {
209
- const raw = await git(root, ['cat-file', 'commit', commitSha]);
210
- const lines = raw.split('\n');
260
+ const raw = await gitRaw(root, ['cat-file', 'commit', commitSha]);
261
+ const separator = raw.indexOf('\n\n');
262
+ if (separator === -1) {
263
+ throw new Error(`malformed commit object ${commitSha}: missing header/message separator`);
264
+ }
265
+ const message = raw.slice(separator + 2);
211
266
  const headers = {};
212
- const messageLines = [];
213
- let inBody = false;
214
- for (const line of lines) {
215
- if (inBody) {
216
- messageLines.push(line);
217
- continue;
218
- }
219
- if (line === '') {
220
- inBody = true;
221
- continue;
222
- }
223
- const colon = line.indexOf(' ');
224
- if (colon === -1) continue;
225
- const key = line.slice(0, colon);
226
- const value = line.slice(colon + 1);
227
- headers[key] = value;
267
+ for (const line of raw.slice(0, separator).split('\n')) {
268
+ if (line.startsWith(' ')) continue; // continuation line (e.g. gpgsig)
269
+ const space = line.indexOf(' ');
270
+ if (space === -1) continue;
271
+ headers[line.slice(0, space)] = line.slice(space + 1);
228
272
  }
229
- const message = messageLines.join('\n');
230
- const authorParts = (headers.author ?? '').split(/\s+>/);
231
- const committerParts = (headers.committer ?? '').split(/\s+>/);
273
+ const author = parseCommitIdent(headers.author ?? '');
274
+ const committer = parseCommitIdent(headers.committer ?? '');
232
275
  return {
233
276
  message,
234
277
  author: {
235
- name: authorParts[0] ?? '',
236
- email: (authorParts[1] ?? '').replace(/^</, '').replace(/\d+ [+-]\d{4}$/, '').trim(),
237
- date: (headers.author ?? '').match(/(\d+ [+-]\d{4})$/)?.[1] ?? '',
278
+ name: author.name,
279
+ email: author.email,
280
+ date: `${author.ts} ${author.tz}`,
238
281
  },
239
282
  committer: {
240
- name: committerParts[0] ?? '',
241
- email: (committerParts[1] ?? '').replace(/^</, '').replace(/\d+ [+-]\d{4}$/, '').trim(),
242
- date: (headers.committer ?? '').match(/(\d+ [+-]\d{4})$/)?.[1] ?? '',
283
+ name: committer.name,
284
+ email: committer.email,
285
+ date: `${committer.ts} ${committer.tz}`,
243
286
  },
244
287
  };
245
288
  }
@@ -384,20 +427,25 @@ export async function analyzeLineage(root) {
384
427
  * Create a rebuilt commit object with `commit-tree`, preserving the original
385
428
  * author/committer identity and dates via environment variables.
386
429
  *
430
+ * The message is delivered via stdin (`-F -`) rather than `-m`: `commit-tree
431
+ * -m` strips trailing blank lines, while stdin preserves the original message
432
+ * bytes exactly (including trailing whitespace), so a rebuilt node can be
433
+ * byte-identical to the original commit.
434
+ *
387
435
  * Writes ONLY a commit object into the local object database. Never writes a
388
436
  * ref and never pushes.
389
437
  *
390
438
  * @param {string} root - repository root
391
439
  * @param {string} treeSha - tree hash for the rebuilt commit
392
440
  * @param {string|null} parentSha - parent commit (null → chain root)
393
- * @param {string} message - commit message
441
+ * @param {string} message - commit message (exact bytes)
394
442
  * @param {{ author: {name: string, email: string, date: string}, committer: {name: string, email: string, date: string} }} identity
395
443
  * @returns {Promise<string>} rebuilt commit SHA
396
444
  */
397
445
  export async function createRebuiltCommit(root, treeSha, parentSha, message, identity) {
398
446
  const args = ['commit-tree', treeSha];
399
447
  if (parentSha) args.push('-p', parentSha);
400
- args.push('-m', message);
448
+ args.push('-F', '-');
401
449
  const env = {
402
450
  ...process.env,
403
451
  GIT_AUTHOR_NAME: identity.author.name,
@@ -407,8 +455,29 @@ export async function createRebuiltCommit(root, treeSha, parentSha, message, ide
407
455
  GIT_COMMITTER_EMAIL: identity.committer.email,
408
456
  GIT_COMMITTER_DATE: identity.committer.date,
409
457
  };
410
- const { stdout } = await execFile('git', args, { cwd: root, encoding: 'utf8', env });
411
- return stdout.trim();
458
+ // Async execFile has no stdin-input support (options.input would be ignored
459
+ // and the child would hang waiting on an open stdin), so spawn is used to
460
+ // deliver the exact message bytes on stdin.
461
+ return new Promise((resolvePromise, rejectPromise) => {
462
+ const child = spawn('git', args, { cwd: root, env });
463
+ let stdout = '';
464
+ let stderr = '';
465
+ child.stdout.setEncoding('utf8');
466
+ child.stderr.setEncoding('utf8');
467
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
468
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
469
+ child.on('error', rejectPromise);
470
+ child.on('close', (code) => {
471
+ if (code !== 0) {
472
+ rejectPromise(new Error(`git commit-tree failed (exit ${code}): ${stderr.trim()}`));
473
+ return;
474
+ }
475
+ resolvePromise(stdout.trim());
476
+ });
477
+ child.stdin.on('error', () => {}); // EPIPE after early exit is reported via close
478
+ child.stdin.write(message);
479
+ child.stdin.end();
480
+ });
412
481
  }
413
482
 
414
483
  /**
@@ -41,6 +41,7 @@ import {
41
41
  CHECKER_VERSION as SKILL_RESOURCE_CHECKER_VERSION,
42
42
  checkSkillResourceClosure,
43
43
  createSkillResourceClosureReceipt,
44
+ evaluateDeclaredHostSurfaceCoverage,
44
45
  } from '../core/skill-resource-closure.mjs';
45
46
  import { buildPublicStaging } from '../snapshot/public-map.mjs';
46
47
  import { resolveUnitScopedPath } from '../snapshot/public-path.mjs';
@@ -2858,7 +2859,16 @@ export async function prepareRelease(options) {
2858
2859
  host: 'root',
2859
2860
  });
2860
2861
 
2861
- const receipt = createSkillResourceClosureReceipt(closureResult, { unitId: unit.id });
2862
+ // G5: bind execution time + exit code into the frozen receipt.
2863
+ // preparedAt reuses this prepare's deterministic freeze timestamp
2864
+ // (production: the baseline HEAD commit committer date; otherwise
2865
+ // null) — never a wall-clock sample — so identical sources freeze
2866
+ // byte-identical receipts on every re-prepare.
2867
+ const receipt = createSkillResourceClosureReceipt(closureResult, {
2868
+ unitId: unit.id,
2869
+ preparedAt: freezeTimestamp ?? null,
2870
+ exitCode: 0,
2871
+ });
2862
2872
  skillResourceClosureResults.push(receipt);
2863
2873
 
2864
2874
  if (closureResult.findings.length > 0) {
@@ -2873,6 +2883,9 @@ export async function prepareRelease(options) {
2873
2883
  reference: f.reference,
2874
2884
  classification: f.classification,
2875
2885
  code: f.code,
2886
+ // D4: RESOURCE_DRIFT findings localize via references (the
2887
+ // finding's own skill/line stay null for a cross-surface drift).
2888
+ ...(f.references ? { references: f.references } : {}),
2876
2889
  })),
2877
2890
  });
2878
2891
  throw new ReleaseError(
@@ -2886,6 +2899,47 @@ export async function prepareRelease(options) {
2886
2899
  );
2887
2900
  }
2888
2901
 
2902
+ // G4: every declared plugin distribution must be backed by a host
2903
+ // surface in the frozen snapshot with at least one skill. If
2904
+ // publicFiles drops an adapter tree, that host surface is silently
2905
+ // absent from the receipt — fail closed here instead of shipping a
2906
+ // unit whose declared host never entered the closure gate.
2907
+ // Expected host names are the adapter directory names declared by the
2908
+ // platform registry (buildAdapter.name, asserted non-empty by
2909
+ // assertRegistry); codebuddy-plugin keeps the historical `workbuddy`
2910
+ // adapter directory name there. npm-only units declare no plugin
2911
+ // hosts and skip.
2912
+ const expectedHosts = (unit.distributions ?? [])
2913
+ .map((distribution) => PLATFORMS.find((platform) => platform.distributionType === distribution.type))
2914
+ .filter(Boolean)
2915
+ .map((platform) => platform.buildAdapter.name);
2916
+ const hostCoverage = evaluateDeclaredHostSurfaceCoverage(
2917
+ expectedHosts,
2918
+ closureResult.surfaces,
2919
+ );
2920
+ if (!hostCoverage.passed) {
2921
+ await evidence.append({
2922
+ phase: 'skill-resource-closure',
2923
+ status: 'blocking',
2924
+ unitId: unit.id,
2925
+ reason: 'declared-host-surface-missing',
2926
+ missingHosts: hostCoverage.missing,
2927
+ });
2928
+ throw new ReleaseError(
2929
+ GATE_FAILED,
2930
+ `skill resource closure gate failed for unit "${unit.id}": declared host surface(s) missing or empty: ${hostCoverage.missing.map((item) => item.host).join(', ')}`,
2931
+ {
2932
+ unitId: unit.id,
2933
+ missingHosts: hostCoverage.missing,
2934
+ observedSurfaces: closureResult.surfaces.map((surface) => ({
2935
+ id: surface.id,
2936
+ host: surface.host,
2937
+ skillCount: surface.skillCount,
2938
+ })),
2939
+ },
2940
+ );
2941
+ }
2942
+
2889
2943
  await evidence.append({
2890
2944
  phase: 'skill-resource-closure',
2891
2945
  status: 'completed',
@@ -2895,6 +2949,10 @@ export async function prepareRelease(options) {
2895
2949
  skillCount: receipt.skillCount,
2896
2950
  referenceCount: closureResult.referenceCount,
2897
2951
  sourceOnlyCount: closureResult.sourceOnlyCount,
2952
+ // D2: per-reference exemption detail for approval/audit review —
2953
+ // evidence-layer only; the receipt object (and its digest binding)
2954
+ // is intentionally left unchanged.
2955
+ sourceOnlyReferences: closureResult.sourceOnlyReferences,
2898
2956
  findingCount: 0,
2899
2957
  receiptDigest: closureResult.receiptDigest,
2900
2958
  });
@@ -652,7 +652,16 @@ export async function publishRelease(options) {
652
652
  { unitId, findings: closureResult.findings },
653
653
  );
654
654
  }
655
- const observed = createSkillResourceClosureReceipt(closureResult, { unitId });
655
+ // G5: preparedAt/exitCode are record-layer fields frozen by prepare
656
+ // (bound by the plan digest); they cannot be recomputed from the
657
+ // snapshot, so the recheck carries them forward from the expected
658
+ // receipt and the strict comparison below verifies every re-derivable
659
+ // field against the frozen snapshot.
660
+ const observed = createSkillResourceClosureReceipt(closureResult, {
661
+ unitId,
662
+ preparedAt: expected.preparedAt ?? null,
663
+ exitCode: expected.exitCode ?? 0,
664
+ });
656
665
  assertSkillResourceClosureReceipt(expected, observed, `unit "${unitId}"`);
657
666
  }
658
667