release-skill 0.6.0 → 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 (45) 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 +12 -8
  10. package/README.zh-CN.md +12 -8
  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 +1028 -423
  14. package/adapters/claude/schemas/release-plan.schema.json +9 -0
  15. package/adapters/claude/schemas/release-project.schema.json +8 -0
  16. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  17. package/adapters/codex/bin/release-skill.bundle.mjs +1028 -423
  18. package/adapters/codex/schemas/release-plan.schema.json +9 -0
  19. package/adapters/codex/schemas/release-project.schema.json +8 -0
  20. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  21. package/adapters/kimi/bin/release-skill.bundle.mjs +1028 -423
  22. package/adapters/kimi/schemas/release-plan.schema.json +9 -0
  23. package/adapters/kimi/schemas/release-project.schema.json +8 -0
  24. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  25. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1028 -423
  26. package/adapters/workbuddy/schemas/release-plan.schema.json +9 -0
  27. package/adapters/workbuddy/schemas/release-project.schema.json +8 -0
  28. package/bin/release-skill-cli.mjs +11 -0
  29. package/bin/release-skill.bundle.mjs +1028 -423
  30. package/package.json +1 -1
  31. package/references/05-evidence-and-errors.md +1 -0
  32. package/schemas/release-plan.schema.json +9 -0
  33. package/schemas/release-project.schema.json +8 -0
  34. package/scripts/build-bundle.mjs +11 -2
  35. package/scripts/sync-public-files.mjs +4 -0
  36. package/src/commands/lineage.mjs +101 -32
  37. package/src/commands/prepare.mjs +273 -9
  38. package/src/commands/publish.mjs +10 -1
  39. package/src/commands/verify.mjs +22 -0
  40. package/src/core/bundle-freshness.mjs +236 -0
  41. package/src/core/errors.mjs +2 -0
  42. package/src/core/frozen-marker.mjs +97 -0
  43. package/src/core/hooks.mjs +12 -1
  44. package/src/core/skill-resource-closure.mjs +240 -10
  45. 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.0",
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": "广州市风荷科技有限公司"
@@ -32,6 +32,7 @@
32
32
  | `NOT_DEFAULT` | 配置分支不是远端实际默认分支 | 默认分支在 prepare 后改变,或配置错误 | 人工确认远端默认分支,更新配置并重新 prepare |
33
33
  | `CONTENT_MISMATCH` | 远端默认分支缺少冻结源码内容 | README、版本源、公开映射输入的内容或 mode 不一致 | 人工 merge/adopt/reject;接受的内容进入默认分支后重试 publish |
34
34
  | `DIRTY_SOURCE_INPUT` | 源码输入闭包存在未提交变化 | `publicFiles.from` 或 `version.source` 有 staged、unstaged、untracked 变化 | 提交或撤销这些具体输入的变化后重新 prepare;无关 dirty 不受影响 |
35
+ | `BUNDLE_STALE` | bundle 与其源码输入失步(fail-closed 前置门禁) | `bin/release-skill.bundle.mjs` 内嵌的源码摘要与当前 `src/` 摘要不一致,或 bundle/内嵌摘要缺失;在 prepare 加载 config 后的早期阶段检测,任何 workflow 均不可豁免 | 按错误提示在 release-skill 包根运行 `node scripts/build-bundle.mjs`(或 `pnpm build`)重建 bundle 后重新 prepare |
35
36
 
36
37
  **动作状态码** (adapter execute/observe/verify):
37
38
 
@@ -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
  }
@@ -873,6 +873,14 @@
873
873
  },
874
874
  "uniqueItems": true,
875
875
  "description": "Root-relative globs declaring the hook's full input set (source, scripts, config, lockfiles). The cache key fingerprints the content of every matched file; a glob that matches nothing fails closed. Must cover ALL inputs or the cache can return a false hit."
876
+ },
877
+ "testSelection": {
878
+ "type": "string",
879
+ "enum": [
880
+ "full",
881
+ "incremental"
882
+ ],
883
+ "description": "Only meaningful for the test hook: which test selection the hook command runs. Absence or 'full' means the hook runs the full suite, which prepare's built-in full-test freeze gate requires before the plan digest is computed. 'incremental' is reserved for a future preflight mode and is rejected at freeze time ('incremental selection is not allowed at freeze time'). Built-in gate; cannot be disabled by project overlays."
876
884
  }
877
885
  }
878
886
  },
@@ -21,6 +21,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises';
21
21
  import { existsSync, readFileSync } from 'node:fs';
22
22
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
23
23
  import { createHash } from 'node:crypto';
24
+ import { computeBundleSourceDigest } from '../src/core/bundle-freshness.mjs';
24
25
 
25
26
  const CHECK_MODE = process.argv.includes('--check');
26
27
 
@@ -38,7 +39,12 @@ const OUTFILE = join(PKG_ROOT, 'bin', 'release-skill.bundle.mjs');
38
39
  // dependency: the Claude and Codex adapter closures ship the bundle at a
39
40
  // different depth with no package.json next to it, while the npm closure does.
40
41
  // Reading package.json here (build input) keeps the output deterministic.
41
- function buildBanner(pkgIdentity) {
42
+ //
43
+ // __bundleSourceDigest is the build-time digest of the bundle's source inputs
44
+ // (src/, skills-src/, bin/release-skill-cli.mjs, package.json — algorithm in
45
+ // src/core/bundle-freshness.mjs). prepare's BUNDLE_STALE gate recomputes the
46
+ // digest and fails closed on any mismatch (2026-08-18 investigation §4.2).
47
+ function buildBanner(pkgIdentity, sourceDigest) {
42
48
  return `\
43
49
  // --- release-skill bundle (deterministic build) ---
44
50
  // Compute package root from the bundle's own file location (import.meta.url).
@@ -51,6 +57,8 @@ const __bundlePkgRoot = __bundleResolve(__bundleDirname(__bundleFileURLToPath(im
51
57
  const __bundleRealRequire = __bundleCreateRequire(import.meta.url);
52
58
  // Package identity injected at build time — closure-independent --version probe.
53
59
  const __bundlePkg = Object.freeze(${JSON.stringify(pkgIdentity)});
60
+ // Build-time source digest for the BUNDLE_STALE freshness gate (see above).
61
+ const __bundleSourceDigest = "${sourceDigest}";
54
62
  `;
55
63
  }
56
64
 
@@ -164,7 +172,8 @@ async function buildBundle() {
164
172
  }
165
173
 
166
174
  const pkgJson = JSON.parse(await readFile(join(PKG_ROOT, 'package.json'), 'utf-8'));
167
- const banner = buildBanner({ name: pkgJson.name, version: pkgJson.version });
175
+ const sourceDigest = await computeBundleSourceDigest(PKG_ROOT);
176
+ const banner = buildBanner({ name: pkgJson.name, version: pkgJson.version }, sourceDigest);
168
177
 
169
178
  const identity = Object.freeze({ name: pkgJson.name, version: pkgJson.version });
170
179
 
@@ -232,6 +232,9 @@ async function collectEntries(packageRoot) {
232
232
  // a distribution artifact; the shipped bundle
233
233
  // inlines the adopted capability — matches the npm
234
234
  // pack closure contract)
235
+ // - .release-skill/ (local release runtime state — runs, plans, locks;
236
+ // not in the package.json files whitelist, so it is
237
+ // never part of the npm pack closure)
235
238
  const baseFiles = allFiles.filter((f) => {
236
239
  if (f.startsWith('adapters/')) return false;
237
240
  if (f.startsWith('test/')) return false;
@@ -240,6 +243,7 @@ async function collectEntries(packageRoot) {
240
243
  if (f.startsWith('native/') && f.includes('/build/')) return false;
241
244
  if (f.startsWith('scripts/')) return false;
242
245
  if (f.startsWith('vendor/')) return false;
246
+ if (f.startsWith('.release-skill/')) return false;
243
247
  return true;
244
248
  });
245
249
 
@@ -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
  /**