release-skill 0.1.3 → 0.1.5

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 (97) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +3 -3
  4. package/CHANGELOG.md +56 -0
  5. package/INSTALL.md +69 -32
  6. package/INSTALL.zh-CN.md +64 -26
  7. package/README.md +111 -37
  8. package/README.zh-CN.md +95 -28
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +79371 -0
  12. package/adapters/claude/bin/release-skill.mjs +34 -0
  13. package/adapters/claude/native/safe-write/binding.gyp +40 -0
  14. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  15. package/adapters/claude/native/safe-write/prebuilds.json +24 -0
  16. package/adapters/claude/native/safe-write/src/safe_write.cc +2032 -0
  17. package/adapters/claude/schemas/.render-manifest.json +37 -0
  18. package/adapters/claude/schemas/approval-record.schema.json +115 -0
  19. package/adapters/claude/schemas/artifact-lock.schema.json +111 -0
  20. package/adapters/claude/schemas/artifact-plan.schema.json +52 -0
  21. package/adapters/claude/schemas/artifact-policy.schema.json +76 -0
  22. package/adapters/claude/schemas/evidence-event.schema.json +89 -0
  23. package/adapters/claude/schemas/release-plan.schema.json +860 -0
  24. package/adapters/claude/schemas/release-project.schema.json +759 -0
  25. package/adapters/claude/schemas/release-run.schema.json +342 -0
  26. package/adapters/claude/skills/release-assess/SKILL.md +5 -6
  27. package/adapters/claude/skills/release-help/SKILL.md +11 -18
  28. package/adapters/claude/skills/release-prepare/SKILL.md +6 -6
  29. package/adapters/claude/skills/release-publish/SKILL.md +1 -4
  30. package/adapters/claude/skills/release-reconcile/SKILL.md +5 -5
  31. package/adapters/claude/skills/release-setup/SKILL.md +64 -80
  32. package/adapters/claude/skills/release-verify/SKILL.md +4 -7
  33. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  34. package/adapters/codex/bin/release-skill.bundle.mjs +79371 -0
  35. package/adapters/codex/bin/release-skill.mjs +34 -0
  36. package/adapters/codex/native/safe-write/binding.gyp +40 -0
  37. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  38. package/adapters/codex/native/safe-write/prebuilds.json +24 -0
  39. package/adapters/codex/native/safe-write/src/safe_write.cc +2032 -0
  40. package/adapters/codex/schemas/.render-manifest.json +37 -0
  41. package/adapters/codex/schemas/approval-record.schema.json +115 -0
  42. package/adapters/codex/schemas/artifact-lock.schema.json +111 -0
  43. package/adapters/codex/schemas/artifact-plan.schema.json +52 -0
  44. package/adapters/codex/schemas/artifact-policy.schema.json +76 -0
  45. package/adapters/codex/schemas/evidence-event.schema.json +89 -0
  46. package/adapters/codex/schemas/release-plan.schema.json +860 -0
  47. package/adapters/codex/schemas/release-project.schema.json +759 -0
  48. package/adapters/codex/schemas/release-run.schema.json +342 -0
  49. package/adapters/codex/skills/release-assess/SKILL.md +12 -6
  50. package/adapters/codex/skills/release-help/SKILL.md +18 -18
  51. package/adapters/codex/skills/release-prepare/SKILL.md +13 -6
  52. package/adapters/codex/skills/release-publish/SKILL.md +8 -4
  53. package/adapters/codex/skills/release-reconcile/SKILL.md +12 -5
  54. package/adapters/codex/skills/release-setup/SKILL.md +71 -80
  55. package/adapters/codex/skills/release-verify/SKILL.md +11 -7
  56. package/bin/release-skill-cli.mjs +807 -0
  57. package/bin/release-skill.bundle.mjs +79371 -0
  58. package/bin/release-skill.mjs +23 -788
  59. package/package.json +6 -2
  60. package/references/02-project-config.md +2 -2
  61. package/schemas/release-plan.schema.json +7 -1
  62. package/schemas/release-project.schema.json +31 -0
  63. package/scripts/build-bundle.mjs +133 -0
  64. package/skills/release-assess/SKILL.md +5 -6
  65. package/skills/release-help/SKILL.md +11 -18
  66. package/skills/release-prepare/SKILL.md +6 -6
  67. package/skills/release-publish/SKILL.md +1 -4
  68. package/skills/release-reconcile/SKILL.md +5 -5
  69. package/skills/release-setup/SKILL.md +64 -80
  70. package/skills/release-verify/SKILL.md +4 -7
  71. package/skills-src/release-assess/SKILL.md +5 -6
  72. package/skills-src/release-help/SKILL.md +11 -18
  73. package/skills-src/release-prepare/SKILL.md +6 -6
  74. package/skills-src/release-publish/SKILL.md +1 -4
  75. package/skills-src/release-reconcile/SKILL.md +5 -5
  76. package/skills-src/release-setup/SKILL.md +64 -80
  77. package/skills-src/release-verify/SKILL.md +4 -7
  78. package/src/adapters/npm.mjs +5 -13
  79. package/src/adapters/plugin-marketplace.mjs +137 -34
  80. package/src/artifacts/policy.mjs +4 -7
  81. package/src/artifacts/safe-fs-backend-internal.mjs +69 -21
  82. package/src/commands/prepare.mjs +30 -1
  83. package/src/commands/publish.mjs +1 -0
  84. package/src/commands/reconcile.mjs +4 -1
  85. package/src/commands/setup.mjs +667 -28
  86. package/src/commands/verify.mjs +4 -1
  87. package/src/core/approval.mjs +4 -6
  88. package/src/core/config.mjs +8 -8
  89. package/src/core/pkg-root.mjs +22 -0
  90. package/src/core/plan.mjs +68 -5
  91. package/src/core/run.mjs +4 -4
  92. package/src/core/trusted-resource.mjs +96 -0
  93. package/src/docs/version-gate.mjs +164 -0
  94. package/src/producers/build-adapters.mjs +512 -55
  95. package/src/snapshot/frozen.mjs +133 -3
  96. package/src/snapshot/public-map.mjs +7 -4
  97. package/src/snapshot/scan.mjs +2 -1
@@ -169,13 +169,47 @@ export async function verifyFrozenDirectoryStructure(directory, label = 'frozen
169
169
  await walk(directory);
170
170
  }
171
171
 
172
- export async function verifyFrozenGitRepository({ root, gitObjectDir, commit, tree, parentCommit, exec = execFile }) {
172
+ /**
173
+ * Verify an already-frozen Git object directory against the plan.
174
+ *
175
+ * Consumption only: this never rebuilds objects and never reads the wall
176
+ * clock. When `commitTimestamp` (the plan's `frozenSnapshot.commitTimestamp`)
177
+ * is provided, the frozen commit's real `%aI`/`%cI` dates must match it
178
+ * exactly, so a commit carrying any other author/committer time can never
179
+ * pass pre-publish verification.
180
+ */
181
+ export async function verifyFrozenGitRepository({
182
+ root,
183
+ gitObjectDir,
184
+ commit,
185
+ tree,
186
+ parentCommit,
187
+ commitTimestamp,
188
+ exec = execFile,
189
+ }) {
173
190
  const gitDir = await resolveFrozenPath(root, gitObjectDir, 'frozen git object directory');
174
191
  await verifyFrozenDirectoryStructure(gitDir, 'frozen git object directory');
175
192
  const { stdout } = await exec('git', ['--git-dir', gitDir, 'rev-parse', `${commit}^{tree}`], { shell: false });
176
193
  if (stdout.trim() !== tree) {
177
194
  throw frozenError('frozen git object tree mismatch', { commit, expectedTree: tree, observedTree: stdout.trim() });
178
195
  }
196
+ if (commitTimestamp !== undefined) {
197
+ const expectedTimestamp = normalizeGitTimestamp(commitTimestamp, 'frozen commit timestamp');
198
+ const { stdout: datesOut } = await exec(
199
+ 'git',
200
+ ['--git-dir', gitDir, 'show', '-s', '--format=%aI%n%cI', commit],
201
+ { shell: false },
202
+ );
203
+ const [authorDate, committerDate] = datesOut.trim().split('\n');
204
+ if (authorDate !== expectedTimestamp || committerDate !== expectedTimestamp) {
205
+ throw frozenError('frozen git commit dates do not match the plan freeze timestamp', {
206
+ commit,
207
+ expectedTimestamp,
208
+ observedAuthorDate: authorDate,
209
+ observedCommitterDate: committerDate,
210
+ });
211
+ }
212
+ }
179
213
  if (parentCommit) {
180
214
  if (!/^[a-f0-9]{40,64}$/.test(parentCommit)) {
181
215
  throw frozenError('frozen Git parent must be a full hexadecimal object id');
@@ -242,6 +276,85 @@ async function verifyGitTreeContent({ snapshotDir, repositoryDir, commit, expect
242
276
  }
243
277
  }
244
278
 
279
+ /**
280
+ * Strict ISO 8601 timestamp with an explicit UTC offset (Z or ±HH:MM),
281
+ * optional fractional seconds. Naive/local timestamps (no offset) are rejected
282
+ * so the frozen commit time is never interpreted in the builder's local zone.
283
+ */
284
+ const GIT_ISO8601_STRICT_RE =
285
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
286
+
287
+ /**
288
+ * Validate and canonicalize a plan freeze timestamp.
289
+ *
290
+ * The value must be a non-empty, strict ISO 8601 timestamp with an explicit UTC
291
+ * offset and a parseable calendar date. It is normalized to UTC with second
292
+ * precision and a `+00:00` offset, which is exactly the form Git stores and
293
+ * re-emits via `%aI`/`%cI`. Missing, empty, malformed, or out-of-range values
294
+ * throw a closed `GATE_FAILED` error.
295
+ *
296
+ * Normalization is idempotent: re-normalizing an already-canonical value returns
297
+ * the identical string, so prepare and the Git builder always agree byte-for-byte.
298
+ *
299
+ * @param {unknown} value - Candidate timestamp.
300
+ * @param {string} [label] - Label used in error messages.
301
+ * @returns {string} Canonical `YYYY-MM-DDTHH:MM:SS+00:00` timestamp.
302
+ * @throws {ReleaseError} GATE_FAILED when the value is not a valid timestamp.
303
+ */
304
+ export function normalizeGitTimestamp(value, label = 'freeze timestamp') {
305
+ if (typeof value !== 'string' || value.trim() === '') {
306
+ throw frozenError(`${label} must be a non-empty strict ISO 8601 timestamp string`, {
307
+ received: typeof value === 'string' ? value : typeof value,
308
+ });
309
+ }
310
+ const trimmed = value.trim();
311
+ const match = GIT_ISO8601_STRICT_RE.exec(trimmed);
312
+ if (!match) {
313
+ throw frozenError(`${label} must be strict ISO 8601 with an explicit UTC offset`, {
314
+ received: trimmed,
315
+ });
316
+ }
317
+ const [, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr, zone] = match;
318
+ const inputYear = Number(yearStr);
319
+ const inputMonth = Number(monthStr);
320
+ const inputDay = Number(dayStr);
321
+ const inputHour = Number(hourStr);
322
+ const inputMinute = Number(minuteStr);
323
+ const inputSecond = Number(secondStr);
324
+ if (inputYear < 1000 || inputYear > 9999) {
325
+ throw frozenError(`${label} year must be a four-digit ISO year`, { received: trimmed });
326
+ }
327
+ // Reject impossible calendar dates and out-of-range time fields. Date.UTC
328
+ // silently rolls values over (Feb 30 -> Mar 2), so compare the constructed
329
+ // UTC fields against the declared input fields instead of trusting parsing.
330
+ const probeMs = Date.UTC(inputYear, inputMonth - 1, inputDay, inputHour, inputMinute, inputSecond);
331
+ const probe = new Date(probeMs);
332
+ if (
333
+ probe.getUTCFullYear() !== inputYear || probe.getUTCMonth() !== inputMonth - 1 ||
334
+ probe.getUTCDate() !== inputDay || probe.getUTCHours() !== inputHour ||
335
+ probe.getUTCMinutes() !== inputMinute || probe.getUTCSeconds() !== inputSecond
336
+ ) {
337
+ throw frozenError(`${label} is not a valid calendar date`, { received: trimmed });
338
+ }
339
+ let offsetMs = 0;
340
+ if (zone !== 'Z') {
341
+ const sign = zone.startsWith('-') ? -1 : 1;
342
+ const zoneHours = Number(zone.slice(1, 3));
343
+ const zoneMinutes = Number(zone.slice(4, 6));
344
+ if (zoneHours > 23 || zoneMinutes > 59) {
345
+ throw frozenError(`${label} has an invalid UTC offset`, { received: trimmed });
346
+ }
347
+ offsetMs = sign * (zoneHours * 60 + zoneMinutes) * 60_000;
348
+ }
349
+ const date = new Date(probeMs - offsetMs);
350
+ if (date.getUTCFullYear() < 1000 || date.getUTCFullYear() > 9999) {
351
+ throw frozenError(`${label} normalizes outside the four-digit ISO year range`, { received: trimmed });
352
+ }
353
+ const pad = (num) => String(num).padStart(2, '0');
354
+ return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` +
355
+ `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}+00:00`;
356
+ }
357
+
245
358
  function publicGitRemote({ repo, githubHost = 'github.com' }) {
246
359
  if (typeof repo !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) {
247
360
  throw frozenError('Git parent repo must use owner/name format');
@@ -255,14 +368,31 @@ function publicGitRemote({ repo, githubHost = 'github.com' }) {
255
368
  return `https://${githubHost}/${repo}.git`;
256
369
  }
257
370
 
371
+ /**
372
+ * Build the frozen release Git objects for a sealed public snapshot.
373
+ *
374
+ * `commitTimestamp` is the plan freeze timestamp sampled exactly once during
375
+ * production prepare. It is strictly validated BEFORE any filesystem or Git
376
+ * write (missing, empty, malformed, or impossible values fail closed), then
377
+ * written verbatim to both GIT_AUTHOR_DATE and GIT_COMMITTER_DATE, so the
378
+ * frozen commit's `%aI` and `%cI` always equal the plan's
379
+ * `frozenSnapshot.commitTimestamp` byte-for-byte. Identical inputs therefore
380
+ * yield identical tree and commit identifiers, and a different freeze
381
+ * timestamp alone yields a different commit with an identical tree.
382
+ */
258
383
  export async function buildFrozenGitRepository({
259
384
  snapshotDir,
260
385
  repositoryDir,
261
386
  version,
262
387
  expectedSnapshotDigest,
263
388
  parent,
389
+ commitTimestamp,
264
390
  exec = execFile,
265
391
  }) {
392
+ // Fail closed before creating the object directory or running any Git
393
+ // command: a frozen release commit may never inherit the builder's wall
394
+ // clock, an ancient constant, or a value Git would reinterpret.
395
+ const canonicalTimestamp = normalizeGitTimestamp(commitTimestamp, 'frozen commit timestamp');
266
396
  if (!expectedSnapshotDigest) throw frozenError('Git object build requires the sealed snapshot digest');
267
397
  await mkdir(repositoryDir, { recursive: true });
268
398
  await exec('git', ['init', '--bare', repositoryDir], { shell: false });
@@ -312,8 +442,8 @@ export async function buildFrozenGitRepository({
312
442
  GIT_AUTHOR_EMAIL: 'release-skill@localhost',
313
443
  GIT_COMMITTER_NAME: 'release-skill',
314
444
  GIT_COMMITTER_EMAIL: 'release-skill@localhost',
315
- GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z',
316
- GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z',
445
+ GIT_AUTHOR_DATE: canonicalTimestamp,
446
+ GIT_COMMITTER_DATE: canonicalTimestamp,
317
447
  };
318
448
  const commitArgs = ['--git-dir', repositoryDir, 'commit-tree', tree];
319
449
  if (parentCommit) commitArgs.push('-p', parentCommit);
@@ -281,6 +281,7 @@ export function normalizePublicMappings({ unit } = {}) {
281
281
  return Object.freeze(
282
282
  unit.publicFiles.map((mapping) => {
283
283
  const from = canonicalPublicPath(mapping.from).path;
284
+ const sourceScope = mapping.sourceScope ?? 'unit';
284
285
  if (isReservedReleaseControlPath(from)) {
285
286
  throw new ReleaseError(
286
287
  PUBLIC_PATH_FORBIDDEN,
@@ -291,7 +292,7 @@ export function normalizePublicMappings({ unit } = {}) {
291
292
  const sourceRelative = posix.relative(sourcePrefix, from);
292
293
 
293
294
  // Reject `from` paths that escape `unit.source`.
294
- if (sourceRelative.startsWith('../') || sourceRelative === '..') {
295
+ if (sourceScope === 'unit' && (sourceRelative.startsWith('../') || sourceRelative === '..')) {
295
296
  throw new ReleaseError(
296
297
  PUBLIC_PATH_FORBIDDEN,
297
298
  `public file "${mapping.from}" is outside unit source "${unit.source}"`,
@@ -301,6 +302,7 @@ export function normalizePublicMappings({ unit } = {}) {
301
302
 
302
303
  return Object.freeze({
303
304
  from,
305
+ sourceScope,
304
306
  sourceRelative,
305
307
  to: canonicalPublicPath(mapping.to).path,
306
308
  mode: mapping.mode,
@@ -576,7 +578,7 @@ export async function buildPublicStaging({
576
578
  continue;
577
579
  }
578
580
  // Also check real containment inside unit root
579
- if (!isContained(realUnitRoot, realSrc)) {
581
+ if (mapping.sourceScope === 'unit' && !isContained(realUnitRoot, realSrc)) {
580
582
  preflightRecords.push({
581
583
  ok: false,
582
584
  err: new ReleaseError(
@@ -988,7 +990,8 @@ export async function buildPublicStaging({
988
990
  );
989
991
  }
990
992
 
991
- // Verify realpath of the handle is inside both realSourceRoot and realUnitRoot.
993
+ // Verify realpath of the handle is inside the workspace and, for the
994
+ // default unit scope, inside the unit root as well.
992
995
  // This catches a race between preflight and open where a component
993
996
  // was replaced with a symlink.
994
997
  const handleRealPath = await fs.realpath(srcPath, { stage: 'source-open-realpath' });
@@ -999,7 +1002,7 @@ export async function buildPublicStaging({
999
1002
  { from: mapping.from, realPath: handleRealPath },
1000
1003
  );
1001
1004
  }
1002
- if (!isContained(realUnitRoot, handleRealPath)) {
1005
+ if (mapping.sourceScope === 'unit' && !isContained(realUnitRoot, handleRealPath)) {
1003
1006
  throw new ReleaseError(
1004
1007
  SNAPSHOT_FIDELITY_FAILED,
1005
1008
  `source escapes unitRoot containment after open: "${mapping.from}"`,
@@ -49,7 +49,8 @@ const BINARY_PROBE_SIZE = 8192;
49
49
  */
50
50
  const ABSOLUTE_PATH_PATTERNS = [
51
51
  /\/(?:Users|home)\/[A-Za-z0-9_-][A-Za-z0-9._-]*(?:\/[^\s"'`<>]*)?/,
52
- /\/(?:root|tmp)\/[A-Za-z0-9_-][A-Za-z0-9._-]*(?:\/[^\s"'`<>]*)?/,
52
+ /\/root\/[A-Za-z0-9_-][A-Za-z0-9._-]*(?:\/[^\s"'`<>]*)?/,
53
+ /\/tmp\/(?=[^\s"'`<>]*(?:secret|token|credential|password|private[-_]?key|id_rsa))[^\s"'`<>]+/i,
53
54
  /(?:^|[^A-Za-z0-9_])[A-Za-z]:\\[A-Za-z0-9_$-][A-Za-z0-9._$-]*(?:\\[^\s"'`<>]*)?/,
54
55
  ];
55
56