release-skill 0.1.3 → 0.1.4

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 (95) 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 +29 -0
  5. package/INSTALL.md +69 -32
  6. package/INSTALL.zh-CN.md +64 -26
  7. package/README.md +101 -37
  8. package/README.zh-CN.md +87 -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 +79284 -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 +736 -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 +79284 -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 +736 -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 +79284 -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 +8 -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 +67 -31
  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 +22 -1
  83. package/src/commands/publish.mjs +1 -0
  84. package/src/commands/setup.mjs +667 -28
  85. package/src/core/approval.mjs +4 -6
  86. package/src/core/config.mjs +8 -8
  87. package/src/core/pkg-root.mjs +22 -0
  88. package/src/core/plan.mjs +9 -4
  89. package/src/core/run.mjs +4 -4
  90. package/src/core/trusted-resource.mjs +96 -0
  91. package/src/docs/version-gate.mjs +164 -0
  92. package/src/producers/build-adapters.mjs +512 -55
  93. package/src/snapshot/frozen.mjs +133 -3
  94. package/src/snapshot/public-map.mjs +7 -4
  95. package/src/snapshot/scan.mjs +2 -1
@@ -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)
@@ -386,6 +386,11 @@ export function validatePlanActionCompleteness(plan) {
386
386
  if (frozen.branchStrategy !== branchStrategy) {
387
387
  failures.push(`unit "${unitId}" frozenSnapshot.branchStrategy does not match productionConfig.branchStrategy`);
388
388
  }
389
+ if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
390
+ failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
391
+ } else if (plan.createdAt !== frozen.commitTimestamp) {
392
+ failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp must equal plan.createdAt`);
393
+ }
389
394
  if (['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
390
395
  if (unit.previousPublicBaseline?.mode !== 'bound') {
391
396
  failures.push(`unit "${unitId}" branch strategy "${branchStrategy}" requires a bound previous public baseline`);
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
+ }