happyskills 0.31.1 → 0.32.0

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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.32.0] - 2026-04-07
11
+
12
+ ### Added
13
+ - Add changelog version validation to `validate` and `publish` — verifies that the top `## [x.y.z]` entry in CHANGELOG.md matches the `skill.json` version before publishing, preventing stale or missing changelog releases
14
+
10
15
  ## [0.31.1] - 2026-04-07
11
16
 
12
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.31.1",
3
+ "version": "0.32.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -5,6 +5,7 @@ const { validate_skill_json } = require('../validation/skill_json_rules')
5
5
  const { validate_cross } = require('../validation/cross_rules')
6
6
  const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
7
7
  const { validate_file_sizes } = require('../validation/file_size_rules')
8
+ const { validate_changelog_version } = require('../validation/changelog_rules')
8
9
  const { file_exists, read_json } = require('../utils/fs')
9
10
  const { skills_dir, find_project_root } = require('../config/paths')
10
11
  const { print_help, print_json } = require('../ui/output')
@@ -150,8 +151,10 @@ const run = (args) => catch_errors('Validate failed', async () => {
150
151
  if (marker_err) throw marker_err
151
152
  const [size_err, size_results] = await validate_file_sizes(skill_dir)
152
153
  if (size_err) throw size_err
154
+ const [cl_err, cl_results] = await validate_changelog_version(skill_dir, json_data.manifest)
155
+ if (cl_err) throw cl_err
153
156
 
154
- const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results]
157
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results]
155
158
  const type_label = is_kit ? ' [kit]' : ''
156
159
 
157
160
  if (args.flags.json) {
@@ -0,0 +1,64 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { error: { catch_errors } } = require('puffy-core')
4
+
5
+ const CHANGELOG = 'CHANGELOG.md'
6
+ const VERSION_HEADING_RE = /^##\s+\[(\d+\.\d+\.\d+[^\]]*)\]/
7
+
8
+ /**
9
+ * Validates that the CHANGELOG.md top version entry matches skill.json version.
10
+ * Only runs when both CHANGELOG.md and skill.json exist — skills without a
11
+ * changelog are not penalised.
12
+ *
13
+ * @param {string} skill_dir - Absolute path to the skill directory
14
+ * @param {object|null} manifest - Parsed skill.json (may be null if missing)
15
+ * @returns {[errors, results[]]}
16
+ */
17
+ const validate_changelog_version = (skill_dir, manifest) => catch_errors('Failed to validate changelog', async () => {
18
+ const changelog_path = path.join(skill_dir, CHANGELOG)
19
+ let content
20
+ try { content = await fs.promises.readFile(changelog_path, 'utf-8') } catch { return [] }
21
+
22
+ // No manifest means skill.json is missing — other rules already flag that
23
+ if (!manifest || !manifest.version) return []
24
+
25
+ const lines = content.split('\n')
26
+ let first_version = null
27
+ for (const line of lines) {
28
+ const m = line.match(VERSION_HEADING_RE)
29
+ if (m) {
30
+ first_version = m[1]
31
+ break
32
+ }
33
+ }
34
+
35
+ if (!first_version) {
36
+ return [{
37
+ file: CHANGELOG,
38
+ field: null,
39
+ rule: 'changelog_has_version',
40
+ severity: 'warning',
41
+ message: 'CHANGELOG.md exists but has no version entry (expected ## [x.y.z])'
42
+ }]
43
+ }
44
+
45
+ if (first_version !== manifest.version) {
46
+ return [{
47
+ file: CHANGELOG,
48
+ field: 'version',
49
+ rule: 'changelog_version_match',
50
+ severity: 'error',
51
+ message: `CHANGELOG.md top version [${first_version}] does not match skill.json version ${manifest.version}`
52
+ }]
53
+ }
54
+
55
+ return [{
56
+ file: CHANGELOG,
57
+ field: null,
58
+ rule: 'changelog_version_match',
59
+ severity: 'pass',
60
+ message: `CHANGELOG.md version matches skill.json (${manifest.version})`
61
+ }]
62
+ })
63
+
64
+ module.exports = { validate_changelog_version }
@@ -0,0 +1,90 @@
1
+ const { describe, it, beforeEach, afterEach } = require('node:test')
2
+ const assert = require('node:assert/strict')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const os = require('os')
6
+ const { validate_changelog_version } = require('./changelog_rules')
7
+
8
+ const make_temp_dir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-test-'))
9
+ const clean = (dir) => fs.rmSync(dir, { recursive: true, force: true })
10
+
11
+ describe('validate_changelog_version', () => {
12
+ let dir
13
+
14
+ beforeEach(() => { dir = make_temp_dir() })
15
+ afterEach(() => { clean(dir) })
16
+
17
+ it('returns empty when no CHANGELOG.md exists', async () => {
18
+ const [err, results] = await validate_changelog_version(dir, { version: '1.0.0' })
19
+ assert.strictEqual(err, null)
20
+ assert.strictEqual(results.length, 0)
21
+ })
22
+
23
+ it('returns empty when manifest is null', async () => {
24
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '## [1.0.0] - 2026-01-01\n')
25
+ const [err, results] = await validate_changelog_version(dir, null)
26
+ assert.strictEqual(err, null)
27
+ assert.strictEqual(results.length, 0)
28
+ })
29
+
30
+ it('returns empty when manifest has no version', async () => {
31
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '## [1.0.0] - 2026-01-01\n')
32
+ const [err, results] = await validate_changelog_version(dir, {})
33
+ assert.strictEqual(err, null)
34
+ assert.strictEqual(results.length, 0)
35
+ })
36
+
37
+ it('returns pass when versions match', async () => {
38
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '# Changelog\n\n## [2.1.0] - 2026-04-07\n\n### Added\n- Something\n')
39
+ const [err, results] = await validate_changelog_version(dir, { version: '2.1.0' })
40
+ assert.strictEqual(err, null)
41
+ assert.strictEqual(results.length, 1)
42
+ assert.strictEqual(results[0].severity, 'pass')
43
+ assert.strictEqual(results[0].rule, 'changelog_version_match')
44
+ })
45
+
46
+ it('returns error when versions mismatch', async () => {
47
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n')
48
+ const [err, results] = await validate_changelog_version(dir, { version: '1.1.0' })
49
+ assert.strictEqual(err, null)
50
+ assert.strictEqual(results.length, 1)
51
+ assert.strictEqual(results[0].severity, 'error')
52
+ assert.strictEqual(results[0].rule, 'changelog_version_match')
53
+ assert.ok(results[0].message.includes('[1.0.0]'))
54
+ assert.ok(results[0].message.includes('1.1.0'))
55
+ })
56
+
57
+ it('uses the first version heading, not later ones', async () => {
58
+ const content = '# Changelog\n\n## [2.0.0] - 2026-04-07\n\n## [1.0.0] - 2026-01-01\n'
59
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), content)
60
+ const [err, results] = await validate_changelog_version(dir, { version: '2.0.0' })
61
+ assert.strictEqual(err, null)
62
+ assert.strictEqual(results[0].severity, 'pass')
63
+ })
64
+
65
+ it('skips [Unreleased] heading and finds the first real version', async () => {
66
+ const content = '# Changelog\n\n## [Unreleased]\n\n## [1.5.0] - 2026-03-01\n'
67
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), content)
68
+ const [err, results] = await validate_changelog_version(dir, { version: '1.5.0' })
69
+ assert.strictEqual(err, null)
70
+ assert.strictEqual(results[0].severity, 'pass')
71
+ })
72
+
73
+ it('returns warning when changelog has no version entries', async () => {
74
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '# Changelog\n\nNothing here yet.\n')
75
+ const [err, results] = await validate_changelog_version(dir, { version: '1.0.0' })
76
+ assert.strictEqual(err, null)
77
+ assert.strictEqual(results.length, 1)
78
+ assert.strictEqual(results[0].severity, 'warning')
79
+ assert.strictEqual(results[0].rule, 'changelog_has_version')
80
+ })
81
+
82
+ it('handles changelog with only [Unreleased] heading', async () => {
83
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '# Changelog\n\n## [Unreleased]\n\n- WIP stuff\n')
84
+ const [err, results] = await validate_changelog_version(dir, { version: '1.0.0' })
85
+ assert.strictEqual(err, null)
86
+ assert.strictEqual(results.length, 1)
87
+ assert.strictEqual(results[0].severity, 'warning')
88
+ assert.strictEqual(results[0].rule, 'changelog_has_version')
89
+ })
90
+ })