Skip to content

Commit e349225

Browse files
committed
build(release): stamp the osw version into the plugin manifest too
- register .claude-plugin/plugin.json:version with python-semantic-release - set the plugin and skill versions to the current 2.7.0 after the rebase - parametrize the three version guard tests over skill and plugin
1 parent 3ee045b commit e349225

4 files changed

Lines changed: 49 additions & 26 deletions

File tree

‎.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "osl-tasks",
33
"displayName": "OSL task management",
44
"description": "Task and project management for OpenSemanticLab, driven by the osw CLI and MCP server.",
5-
"version": "0.1.0",
5+
"version": "2.7.0",
66
"author": {
77
"name": "OpenSemanticLab"
88
},

‎pyproject.toml‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,11 @@ preview = true
303303

304304
[tool.semantic_release]
305305
# python-semantic-release owns the version: it reads the conventional commits
306-
# on main, bumps the static version below, in CITATION.cff and in the
307-
# osl-tasks skill's frontmatter, updates the changelog, relocks uv.lock,
308-
# commits, tags (vX.Y.Z) and creates the GitHub release. Publishing to PyPI
309-
# happens in the same workflow run (on-release-main.yml).
306+
# on main, bumps the static version below, in CITATION.cff, in the osl-tasks
307+
# skill's frontmatter and in the Claude Code plugin manifest, updates the
308+
# changelog, relocks uv.lock, commits, tags (vX.Y.Z) and creates the GitHub
309+
# release. Publishing to PyPI happens in the same workflow run
310+
# (on-release-main.yml).
310311
commit_parser = "conventional"
311312
tag_format = "v{version}"
312313
commit_message = "chore(release): v{version} [skip ci]"
@@ -318,6 +319,10 @@ version_toml = ["pyproject.toml:project.version"]
318319
version_variables = [
319320
"CITATION.cff:version",
320321
"src/osw/skills/osl-tasks/SKILL.md:version",
322+
# Claude Code caches an installed plugin under its version string, so a
323+
# version that never changes keeps plugin users on the skill they first
324+
# installed.
325+
".claude-plugin/plugin.json:version",
321326
]
322327
# relock so uv.lock's own pin of osw's version stays consistent, and include
323328
# the relocked file in the release commit

‎src/osw/skills/osl-tasks/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name: osl-tasks
33
description: Use when importing local todo or note files into an OpenSemanticLab (OSL) wiki as Task entities, or when listing, filtering and updating tasks that already live in OSL. Drives the generic entity, schema and search operations of the `osw` CLI and MCP server; there are no task-specific commands. Covers the Task, Person and Project category page names, how to read the status and priority vocabularies from the schema, the update-by-uuid rule, the duplicate rule and the link marker written back into the local file.
44
metadata:
5-
version: "2.6.0"
5+
version: "2.7.0"
66
---
77

88
# OSL task management

‎tests/test_cli_ops_skill.py‎

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import json
910
import sys
1011
from pathlib import Path
1112
from unittest.mock import MagicMock
@@ -26,14 +27,21 @@
2627
SKILL_MD_PATH = (
2728
Path(ops.__file__).resolve().parent.parent / "skills" / "osl-tasks" / "SKILL.md"
2829
)
29-
PYPROJECT_PATH = Path(__file__).resolve().parent.parent / "pyproject.toml"
30+
REPO_ROOT = Path(__file__).resolve().parent.parent
31+
PYPROJECT_PATH = REPO_ROOT / "pyproject.toml"
32+
PLUGIN_JSON_PATH = REPO_ROOT / ".claude-plugin" / "plugin.json"
3033
SKILL_VERSION_ENTRY = "src/osw/skills/osl-tasks/SKILL.md:version"
34+
PLUGIN_VERSION_ENTRY = ".claude-plugin/plugin.json:version"
3135

3236

33-
def _skill_frontmatter() -> dict:
37+
def _skill_version() -> str:
3438
# The frontmatter is the YAML between the two leading '---' lines.
3539
_, frontmatter, _ = SKILL_MD_PATH.read_text(encoding="utf-8").split("---", 2)
36-
return yaml.safe_load(frontmatter)
40+
return yaml.safe_load(frontmatter)["metadata"]["version"]
41+
42+
43+
def _plugin_version() -> str:
44+
return json.loads(PLUGIN_JSON_PATH.read_text(encoding="utf-8"))["version"]
3745

3846

3947
def _pyproject() -> dict:
@@ -95,32 +103,44 @@ def test_install_skill_is_registered_on_the_cli_surface_only():
95103
assert registry.REGISTRY["install_skill"].surfaces == frozenset({"cli"})
96104

97105

98-
def test_skill_version_matches_the_package_version():
99-
"""The skill carries the osw version it ships with.
106+
@pytest.mark.parametrize(
107+
"read_version", [_skill_version, _plugin_version], ids=["skill", "plugin"]
108+
)
109+
def test_stamped_version_matches_the_package_version(read_version):
110+
"""The skill and the plugin manifest carry the osw version they ship with.
100111
101-
python-semantic-release rewrites both in the release commit, so they can
102-
only differ when one of them was edited by hand.
112+
python-semantic-release rewrites all three in the release commit, so they
113+
can only differ when one of them was edited by hand.
103114
"""
104-
skill_version = _skill_frontmatter()["metadata"]["version"]
115+
assert read_version() == _pyproject()["project"]["version"]
105116

106-
assert skill_version == _pyproject()["project"]["version"]
107117

108-
109-
def test_skill_is_registered_for_the_release_version_bump():
110-
"""Without this entry a release bumps pyproject.toml but not the skill.
118+
def test_stamped_files_are_registered_for_the_release_version_bump():
119+
"""Without these entries a release bumps pyproject.toml but neither file.
111120
112121
The version check above would only fail after that release, so this
113122
catches a removed entry at once.
114123
"""
115124
variables = _pyproject()["tool"]["semantic_release"]["version_variables"]
116125

117126
assert SKILL_VERSION_ENTRY in variables
127+
assert PLUGIN_VERSION_ENTRY in variables
118128

119129

120-
def test_release_bump_rewrites_only_the_frontmatter_version(monkeypatch):
130+
@pytest.mark.parametrize(
131+
("entry", "path", "changed_line"),
132+
[
133+
(SKILL_VERSION_ENTRY, SKILL_MD_PATH, ' version: "99.99.99"'),
134+
(PLUGIN_VERSION_ENTRY, PLUGIN_JSON_PATH, ' "version": "99.99.99",'),
135+
],
136+
ids=["skill", "plugin"],
137+
)
138+
def test_release_bump_rewrites_only_the_version_line(
139+
entry, path, changed_line, monkeypatch
140+
):
121141
"""python-semantic-release rewrites every match of its pattern in the file.
122142
123-
A body line such as 'version: 1.2.3' or 'osw version 1.2.3' would be
143+
A second line such as 'version: 1.2.3' or 'osw version 1.2.3' would be
124144
rewritten on every release without any test failing, so run the release
125145
tool's own replacement and check that it changes exactly one line.
126146
"""
@@ -131,13 +151,11 @@ def test_release_bump_rewrites_only_the_frontmatter_version(monkeypatch):
131151

132152
# The entry's path is relative to the repository root, where the release
133153
# tool runs.
134-
monkeypatch.chdir(PYPROJECT_PATH.parent)
135-
declaration = PatternVersionDeclaration.from_string_definition(
136-
SKILL_VERSION_ENTRY, "v{version}"
137-
)
138-
before = SKILL_MD_PATH.read_text(encoding="utf-8").splitlines()
154+
monkeypatch.chdir(REPO_ROOT)
155+
declaration = PatternVersionDeclaration.from_string_definition(entry, "v{version}")
156+
before = path.read_text(encoding="utf-8").splitlines()
139157
after = declaration.replace(Version.parse("99.99.99")).splitlines()
140158

141159
assert len(after) == len(before)
142160
changed = [new for old, new in zip(before, after) if old != new]
143-
assert changed == [' version: "99.99.99"']
161+
assert changed == [changed_line]

0 commit comments

Comments
 (0)