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
@@ -669,7 +669,10 @@ export async function verifyRelease(options) {
669
669
  }
670
670
 
671
671
  // Validate plan action completeness before checkpoint mapping
672
- const completenessResult = validatePlanActionCompleteness(plan);
672
+ // Use legacyCompatibility: old plans (pre-v0.1.5) lack
673
+ // parameters.timeoutMs. Verify must still pass these plans,
674
+ // while strict mode (prepare/approve/publish) rejects them.
675
+ const completenessResult = validatePlanActionCompleteness(plan, { legacyCompatibility: true });
673
676
  if (!completenessResult.passed) {
674
677
  throw new ReleaseError(
675
678
  GATE_FAILED,
@@ -19,16 +19,14 @@ import { computePlanDigest } from './plan.mjs';
19
19
  import { sha256Hex } from './digest.mjs';
20
20
  import { readFile } from 'node:fs/promises';
21
21
  import { basename, dirname, join, resolve } from 'node:path';
22
- import { fileURLToPath } from 'node:url';
23
22
  import Ajv from 'ajv';
24
23
  import addFormats from 'ajv-formats';
25
24
  import { WORKSPACE_DIGEST_ALGORITHM } from './baseline.mjs';
25
+ import { readTrustedPackageResource } from './trusted-resource.mjs';
26
26
 
27
- const __dirname = dirname(fileURLToPath(import.meta.url));
28
- const approvalSchema = JSON.parse(await readFile(
29
- join(__dirname, '..', '..', 'schemas', 'approval-record.schema.json'),
30
- 'utf8',
31
- ));
27
+ const approvalSchema = JSON.parse((await readTrustedPackageResource(
28
+ 'schemas/approval-record.schema.json',
29
+ )).toString('utf8'));
32
30
  const approvalAjv = new Ajv({ allErrors: true, strict: false });
33
31
  addFormats(approvalAjv);
34
32
  const validateApprovalSchema = approvalAjv.compile(approvalSchema);
@@ -17,8 +17,7 @@
17
17
  */
18
18
 
19
19
  import { readFile } from 'node:fs/promises';
20
- import { resolve, isAbsolute, relative, normalize, dirname } from 'node:path';
21
- import { fileURLToPath } from 'node:url';
20
+ import { resolve, isAbsolute, relative, normalize } from 'node:path';
22
21
  import YAML, { Alias } from 'yaml';
23
22
  import Ajv from 'ajv';
24
23
  import addFormats from 'ajv-formats';
@@ -26,6 +25,7 @@ import { canonicalJson, sha256Hex } from './digest.mjs';
26
25
  import { ReleaseError, CONFIG_INVALID } from './errors.mjs';
27
26
  import { canonicalPublicPath, publicPathCollisionKey } from '../snapshot/public-path.mjs';
28
27
  import { isReservedReleaseControlPath } from './baseline.mjs';
28
+ import { readTrustedPackageResource } from './trusted-resource.mjs';
29
29
 
30
30
  // ---------------------------------------------------------------------------
31
31
  // Constants
@@ -38,19 +38,19 @@ const DEFAULT_CONFIG_REL = '.release-skill/project.yaml';
38
38
  // Load the formal release-project JSON Schema (single source of truth)
39
39
  // ---------------------------------------------------------------------------
40
40
 
41
- const __dirname = dirname(fileURLToPath(import.meta.url));
42
- const FORMAL_SCHEMA_PATH = resolve(__dirname, '..', '..', 'schemas', 'release-project.schema.json');
43
-
44
41
  let RELEASE_PROJECT_SCHEMA;
45
42
  try {
46
- const schemaRaw = await readFile(FORMAL_SCHEMA_PATH, 'utf8');
43
+ const schemaRaw = (await readTrustedPackageResource(
44
+ 'schemas/release-project.schema.json',
45
+ )).toString('utf8');
47
46
  RELEASE_PROJECT_SCHEMA = JSON.parse(schemaRaw);
48
47
  } catch (err) {
48
+ if (err instanceof ReleaseError) throw err;
49
49
  // Fail closed: if the formal schema cannot be loaded, refuse to operate.
50
50
  throw new ReleaseError(
51
51
  CONFIG_INVALID,
52
- `cannot load formal release-project schema: ${err.message}`,
53
- { schemaPath: FORMAL_SCHEMA_PATH, cause: err.code },
52
+ 'cannot parse formal release-project schema',
53
+ { resource: 'schemas/release-project.schema.json', cause: err.code ?? 'PARSE_FAILED' },
54
54
  );
55
55
  }
56
56
 
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Package root resolution for bundled and unbundled execution.
3
+ *
4
+ * In development (unbundled) mode, `import.meta.url` points to the source
5
+ * file under `src/core/`, so `../..` reaches the package root.
6
+ *
7
+ * In bundled mode, the esbuild banner defines `__bundlePkgRoot` before the
8
+ * module body executes, derived deterministically from the bundle's own
9
+ * file path. The env-var override path has been removed to prevent
10
+ * callers from hijacking schema/native resolution.
11
+ *
12
+ * @module core/pkg-root
13
+ */
14
+
15
+ import { dirname, resolve } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ // In bundled mode, __bundlePkgRoot is set by the esbuild banner.
19
+ // In development mode, resolve from this source file's location.
20
+ export const PKG_ROOT = typeof __bundlePkgRoot !== 'undefined'
21
+ ? __bundlePkgRoot
22
+ : resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
package/src/core/plan.mjs CHANGED
@@ -17,19 +17,19 @@
17
17
 
18
18
  import { readFile, writeFile, rename, mkdir, open, link, unlink, lstat } from 'node:fs/promises';
19
19
  import { basename, dirname, join, resolve, parse, sep } from 'node:path';
20
- import { fileURLToPath } from 'node:url';
21
20
  import Ajv from 'ajv';
22
21
  import addFormats from 'ajv-formats';
23
22
  import { canonicalJson, sha256Hex } from './digest.mjs';
24
23
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
24
+ import { readTrustedPackageResource } from './trusted-resource.mjs';
25
25
 
26
26
  // ---------------------------------------------------------------------------
27
27
  // Schema loaded from the authoritative JSON file (single source of truth)
28
28
  // ---------------------------------------------------------------------------
29
29
 
30
- const __dirname = dirname(fileURLToPath(import.meta.url));
31
- const SCHEMA_PATH = join(__dirname, '..', '..', 'schemas', 'release-plan.schema.json');
32
- const RELEASE_PLAN_SCHEMA = JSON.parse(await readFile(SCHEMA_PATH, 'utf8'));
30
+ const RELEASE_PLAN_SCHEMA = JSON.parse((await readTrustedPackageResource(
31
+ 'schemas/release-plan.schema.json',
32
+ )).toString('utf8'));
33
33
 
34
34
  // ---------------------------------------------------------------------------
35
35
  // Schema validator (compiled once at module init)
@@ -312,9 +312,13 @@ const REQUIRED_ACTION_TYPES = ['push-snapshot', 'create-tag', 'github-release'];
312
312
  * (e.g. v0.0.10 must not match expected version 0.0.1).
313
313
  *
314
314
  * @param {object} plan - A validated release plan object.
315
+ * @param {object} [options]
316
+ * @param {boolean} [options.legacyCompatibility] - When true, relax
317
+ * timeoutMs requirement for old plans (reconcile/verify paths).
318
+ * New prepare/approve/publish must not set this flag.
315
319
  * @returns {{ passed: boolean, details: { failures: string[], expectedCount: number, actualCount: number } }}
316
320
  */
317
- export function validatePlanActionCompleteness(plan) {
321
+ export function validatePlanActionCompleteness(plan, options = {}) {
318
322
  const failures = [];
319
323
 
320
324
  if (!plan || typeof plan !== 'object') {
@@ -386,6 +390,11 @@ export function validatePlanActionCompleteness(plan) {
386
390
  if (frozen.branchStrategy !== branchStrategy) {
387
391
  failures.push(`unit "${unitId}" frozenSnapshot.branchStrategy does not match productionConfig.branchStrategy`);
388
392
  }
393
+ if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
394
+ failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
395
+ } else if (plan.createdAt !== frozen.commitTimestamp) {
396
+ failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp must equal plan.createdAt`);
397
+ }
389
398
  if (['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
390
399
  if (unit.previousPublicBaseline?.mode !== 'bound') {
391
400
  failures.push(`unit "${unitId}" branch strategy "${branchStrategy}" requires a bound previous public baseline`);
@@ -665,6 +674,33 @@ export function validatePlanActionCompleteness(plan) {
665
674
  _checkRequired(action, 'parameters.ref', action.parameters?.ref, expectedTag, unitId, failures);
666
675
  _checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
667
676
  }
677
+ // timeoutMs is mandatory for all marketplace install actions.
678
+ // Legacy plans (pre-v0.1.5) lack this field; legacyCompatibility
679
+ // relaxes the check for reconcile/verify paths only, and only when
680
+ // the property is genuinely absent (undefined). An explicit null is
681
+ // NOT "absent" -- it is an invalid value and always fails closed,
682
+ // like strings or out-of-range numbers, even in legacyCompatibility.
683
+ {
684
+ const raw = action.parameters?.timeoutMs;
685
+ if (raw === undefined) {
686
+ if (!options.legacyCompatibility) {
687
+ failures.push(
688
+ `unit "${unitId}", action "${action.id}": parameters.timeoutMs is missing, expected a valid timeout (30000-900000)`,
689
+ );
690
+ }
691
+ } else {
692
+ // Field is present -- always validate range/type, even in legacy mode
693
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) {
694
+ failures.push(
695
+ `unit "${unitId}", action "${action.id}": parameters.timeoutMs must be a finite integer, got: ${JSON.stringify(raw)}`,
696
+ );
697
+ } else if (raw < 30000 || raw > 900000) {
698
+ failures.push(
699
+ `unit "${unitId}", action "${action.id}": parameters.timeoutMs must be between 30000 and 900000, got: ${raw}`,
700
+ );
701
+ }
702
+ }
703
+ }
668
704
 
669
705
  // Expected checks
670
706
  _checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
@@ -741,6 +777,33 @@ export function validatePlanActionCompleteness(plan) {
741
777
  _checkRequired(action, 'parameters.ref', action.parameters?.ref, expectedTag, unitId, failures);
742
778
  _checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
743
779
  }
780
+ // timeoutMs is mandatory for all marketplace install actions.
781
+ // Legacy plans (pre-v0.1.5) lack this field; legacyCompatibility
782
+ // relaxes the check for reconcile/verify paths only, and only when
783
+ // the property is genuinely absent (undefined). An explicit null is
784
+ // NOT "absent" -- it is an invalid value and always fails closed,
785
+ // like strings or out-of-range numbers, even in legacyCompatibility.
786
+ {
787
+ const raw = action.parameters?.timeoutMs;
788
+ if (raw === undefined) {
789
+ if (!options.legacyCompatibility) {
790
+ failures.push(
791
+ `unit "${unitId}", action "${action.id}": parameters.timeoutMs is missing, expected a valid timeout (30000-900000)`,
792
+ );
793
+ }
794
+ } else {
795
+ // Field is present -- always validate range/type, even in legacy mode
796
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) {
797
+ failures.push(
798
+ `unit "${unitId}", action "${action.id}": parameters.timeoutMs must be a finite integer, got: ${JSON.stringify(raw)}`,
799
+ );
800
+ } else if (raw < 30000 || raw > 900000) {
801
+ failures.push(
802
+ `unit "${unitId}", action "${action.id}": parameters.timeoutMs must be between 30000 and 900000, got: ${raw}`,
803
+ );
804
+ }
805
+ }
806
+ }
744
807
 
745
808
  // Expected checks
746
809
  _checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
package/src/core/run.mjs CHANGED
@@ -15,17 +15,17 @@
15
15
  import { link, lstat, mkdir, open, readFile, unlink } from 'node:fs/promises';
16
16
  import { realpathSync } from 'node:fs';
17
17
  import { dirname, join, resolve, basename, relative, isAbsolute } from 'node:path';
18
- import { fileURLToPath } from 'node:url';
19
18
  import Ajv from 'ajv';
20
19
  import addFormats from 'ajv-formats';
21
20
 
22
21
  import { assertImmutablePlanAuthority, computePlanDigest } from './plan.mjs';
23
22
  import { sha256Hex } from './digest.mjs';
24
23
  import { ReleaseError, GATE_FAILED } from './errors.mjs';
24
+ import { readTrustedPackageResource } from './trusted-resource.mjs';
25
25
 
26
- const __dirname = dirname(fileURLToPath(import.meta.url));
27
- const RUN_SCHEMA_PATH = join(__dirname, '..', '..', 'schemas', 'release-run.schema.json');
28
- const RELEASE_RUN_SCHEMA = JSON.parse(await readFile(RUN_SCHEMA_PATH, 'utf8'));
26
+ const RELEASE_RUN_SCHEMA = JSON.parse((await readTrustedPackageResource(
27
+ 'schemas/release-run.schema.json',
28
+ )).toString('utf8'));
29
29
 
30
30
  // ---------------------------------------------------------------------------
31
31
  // Default runDir resolution
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Read package-owned runtime resources without following untrusted links.
3
+ * Installed adapters use this for schemas and native manifests.
4
+ */
5
+
6
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
7
+ import { lstat, readFile, realpath } from 'node:fs/promises';
8
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
9
+
10
+ import { CONFIG_INVALID, ReleaseError } from './errors.mjs';
11
+ import { PKG_ROOT } from './pkg-root.mjs';
12
+
13
+ function fail(code, resource, reason) {
14
+ throw new ReleaseError(
15
+ code,
16
+ `package resource is untrusted or unavailable: ${resource}`,
17
+ { resource, reason },
18
+ );
19
+ }
20
+
21
+ function lexicalPath(resource, code) {
22
+ if (typeof resource !== 'string' || resource.length === 0 || isAbsolute(resource)) {
23
+ fail(code, String(resource), 'INVALID_RESOURCE_PATH');
24
+ }
25
+ const path = resolve(PKG_ROOT, resource);
26
+ const rel = relative(PKG_ROOT, path);
27
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
28
+ fail(code, resource, 'RESOURCE_PATH_ESCAPE');
29
+ }
30
+ return path;
31
+ }
32
+
33
+ function assertStat(stat, resource, code) {
34
+ if (stat.isSymbolicLink()) fail(code, resource, 'SYMLINK');
35
+ if (!stat.isFile()) fail(code, resource, 'NOT_REGULAR_FILE');
36
+ if (stat.nlink !== 1) fail(code, resource, 'UNEXPECTED_HARDLINK_COUNT');
37
+ }
38
+
39
+ function assertPhysicalContainment(physicalRoot, physicalPath, resource, code) {
40
+ const rel = relative(physicalRoot, physicalPath);
41
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
42
+ fail(code, resource, 'PHYSICAL_PATH_ESCAPE');
43
+ }
44
+ }
45
+
46
+ export function readTrustedPackageResourceSync(resource, { code = CONFIG_INVALID } = {}) {
47
+ const path = lexicalPath(resource, code);
48
+ let stat;
49
+ try {
50
+ stat = lstatSync(path);
51
+ } catch {
52
+ fail(code, resource, 'MISSING');
53
+ }
54
+ assertStat(stat, resource, code);
55
+
56
+ let physicalRoot;
57
+ let physicalPath;
58
+ try {
59
+ physicalRoot = realpathSync(PKG_ROOT);
60
+ physicalPath = realpathSync(path);
61
+ } catch {
62
+ fail(code, resource, 'REALPATH_FAILED');
63
+ }
64
+ assertPhysicalContainment(physicalRoot, physicalPath, resource, code);
65
+ try {
66
+ return readFileSync(physicalPath);
67
+ } catch {
68
+ fail(code, resource, 'READ_FAILED');
69
+ }
70
+ }
71
+
72
+ export async function readTrustedPackageResource(resource, { code = CONFIG_INVALID } = {}) {
73
+ const path = lexicalPath(resource, code);
74
+ let stat;
75
+ try {
76
+ stat = await lstat(path);
77
+ } catch {
78
+ fail(code, resource, 'MISSING');
79
+ }
80
+ assertStat(stat, resource, code);
81
+
82
+ let physicalRoot;
83
+ let physicalPath;
84
+ try {
85
+ physicalRoot = await realpath(PKG_ROOT);
86
+ physicalPath = await realpath(path);
87
+ } catch {
88
+ fail(code, resource, 'REALPATH_FAILED');
89
+ }
90
+ assertPhysicalContainment(physicalRoot, physicalPath, resource, code);
91
+ try {
92
+ return await readFile(physicalPath);
93
+ } catch {
94
+ fail(code, resource, 'READ_FAILED');
95
+ }
96
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Release docs version consistency gate.
3
+ *
4
+ * README, INSTALL, and CHANGELOG are human-maintained content. A release
5
+ * freezes only the current truth: prepare/publish never rewrite or refresh
6
+ * these docs. Maintainers must update the docs first, then prepare, review,
7
+ * and approve. This gate makes version drift fail closed before release:
8
+ *
9
+ * - the English README, Chinese README, English INSTALL, and Chinese
10
+ * INSTALL each carry a machine-readable current release version marker
11
+ * whose value equals package.json.version exactly;
12
+ * - CHANGELOG.md carries a formal `## [<version>]` heading for the current
13
+ * package version (a body mention or an internal candidate note does not
14
+ * substitute for the heading).
15
+ *
16
+ * The gate is a pure read-only check; it never edits files. It is wired into
17
+ * the package test suite so `pnpm test:release` blocks a prepare when docs
18
+ * have drifted from the package version.
19
+ *
20
+ * @module src/docs/version-gate
21
+ */
22
+
23
+ import { readFile } from 'node:fs/promises';
24
+ import { join } from 'node:path';
25
+
26
+ /** The machine-readable marker tag carried by every public doc file. */
27
+ export const RELEASE_VERSION_MARKER_TAG = 'release-skill:release-version';
28
+
29
+ /**
30
+ * Marker pattern. The value is captured; e.g.
31
+ * <!-- release-skill:release-version: 0.1.4 -->
32
+ * Global: every occurrence is validated so a stale duplicate cannot hide.
33
+ */
34
+ const MARKER_PATTERN = new RegExp(
35
+ `<!--\\s*${RELEASE_VERSION_MARKER_TAG}:\\s*([^\\s>]+)\\s*-->`,
36
+ 'g',
37
+ );
38
+
39
+ /** Public docs that must carry the marker, in both languages. */
40
+ export const VERSIONED_DOC_FILES = [
41
+ 'README.md',
42
+ 'README.zh-CN.md',
43
+ 'INSTALL.md',
44
+ 'INSTALL.zh-CN.md',
45
+ ];
46
+
47
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
48
+
49
+ /**
50
+ * Escape a string for literal use inside a RegExp.
51
+ * @param {string} text
52
+ * @returns {string}
53
+ */
54
+ function escapeRegExp(text) {
55
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
56
+ }
57
+
58
+ /**
59
+ * Extract every release-version marker value from doc content.
60
+ * @param {string} content
61
+ * @returns {string[]}
62
+ */
63
+ export function extractReleaseVersionMarkers(content) {
64
+ return [...String(content ?? '').matchAll(MARKER_PATTERN)].map((match) => match[1]);
65
+ }
66
+
67
+ /**
68
+ * Render the canonical marker line for a version.
69
+ * @param {string} version
70
+ * @returns {string}
71
+ */
72
+ export function renderReleaseVersionMarker(version) {
73
+ return `<!-- ${RELEASE_VERSION_MARKER_TAG}: ${version} -->`;
74
+ }
75
+
76
+ /**
77
+ * Validate that the human-maintained release docs in `packageDir` agree with
78
+ * package.json.version. Read-only; never writes.
79
+ *
80
+ * @param {object} options
81
+ * @param {string} options.packageDir Directory containing package.json and
82
+ * the public docs.
83
+ * @returns {Promise<{
84
+ * passed: boolean,
85
+ * version: string | null,
86
+ * markers: Record<string, string[]>,
87
+ * changelogHeading: boolean,
88
+ * failures: string[],
89
+ * }>}
90
+ */
91
+ export async function validateDocsVersionConsistency({ packageDir }) {
92
+ const failures = [];
93
+ const markers = {};
94
+ let changelogHeading = false;
95
+ let version = null;
96
+
97
+ try {
98
+ const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8'));
99
+ if (typeof pkg.version === 'string' && pkg.version.length > 0) {
100
+ version = pkg.version;
101
+ }
102
+ } catch (error) {
103
+ failures.push(`package.json: cannot read package version (${error?.code ?? error?.message ?? 'unknown error'})`);
104
+ }
105
+ if (version === null) {
106
+ failures.push('package.json: version is missing; docs version gate fails closed');
107
+ } else if (!SEMVER_PATTERN.test(version)) {
108
+ failures.push(`package.json: version "${version}" is not a valid semver value`);
109
+ }
110
+
111
+ if (version !== null) {
112
+ for (const file of VERSIONED_DOC_FILES) {
113
+ let content;
114
+ try {
115
+ content = await readFile(join(packageDir, file), 'utf8');
116
+ } catch {
117
+ failures.push(`${file}: missing; release docs must ship in both languages`);
118
+ continue;
119
+ }
120
+ const found = extractReleaseVersionMarkers(content);
121
+ markers[file] = found;
122
+ if (found.length === 0) {
123
+ failures.push(
124
+ `${file}: missing machine-readable release version marker "${renderReleaseVersionMarker(version)}"`,
125
+ );
126
+ }
127
+ for (const value of found) {
128
+ if (value !== version) {
129
+ failures.push(
130
+ `${file}: release version marker "${value}" does not match package version "${version}"; ` +
131
+ 'update the docs first, then prepare, review, and approve',
132
+ );
133
+ }
134
+ }
135
+ }
136
+
137
+ let changelog;
138
+ try {
139
+ changelog = await readFile(join(packageDir, 'CHANGELOG.md'), 'utf8');
140
+ } catch {
141
+ failures.push('CHANGELOG.md: missing');
142
+ }
143
+ if (changelog !== undefined) {
144
+ // A formal heading starts a line: "## [0.1.4]" optionally followed by a
145
+ // date. A body mention ("0.1.4 was an internal candidate...") does not
146
+ // substitute for the heading.
147
+ changelogHeading = new RegExp(`^##\\s*\\[${escapeRegExp(version)}\\](?:\\s|$)`, 'm').test(changelog);
148
+ if (!changelogHeading) {
149
+ failures.push(
150
+ `CHANGELOG.md: missing formal "## [${version}]" heading for the current package version; ` +
151
+ 'internal unreleased candidates may be explained in the body but cannot replace the entry',
152
+ );
153
+ }
154
+ }
155
+ }
156
+
157
+ return {
158
+ passed: failures.length === 0,
159
+ version,
160
+ markers,
161
+ changelogHeading,
162
+ failures,
163
+ };
164
+ }