Skip to content

Commit 4478a82

Browse files
TMHSDigitalclaude
andcommitted
feat(release): guard skill and rule names across releases
ROADMAP's 1.0 criterion: renaming or removing a skill or rule is a breaking change, because agents and docs cite the names. Nothing enforced it. .github/scripts/removed_names.py lists the skill names (skills/ and live-session/skills/) and rule names that exist at the last tag but not at HEAD. release.yml's bump step runs it. A release that drops a name without a major bump (feat!:/fix!: or a BREAKING CHANGE: footer) fails once VERSION is 1.0 or later, and only warns before that, so a pre-1.0 rename cannot cut 1.0.0 by accident. tests/test_removed_names.py covers a rename, a removed rule, a live-session skill, and a snippet removal that is not a name change. Closes #498 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com>
1 parent 96b6076 commit 4478a82

4 files changed

Lines changed: 151 additions & 0 deletions

File tree

‎.github/scripts/removed_names.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env python3
2+
"""Print the skill and rule names that existed at BASE and are gone at HEAD.
3+
4+
python .github/scripts/removed_names.py v0.153.0
5+
6+
A skill is a directory with a SKILL.md under skills/ or live-session/skills/;
7+
a rule is rules/<name>.mdc. Agents and docs cite these names, so after 1.0
8+
removing or renaming one is a breaking change (ROADMAP "Stable (1.0)", #498).
9+
release.yml fails a release that drops a name without a breaking-change
10+
marker once VERSION is 1.0 or later, and warns before that.
11+
12+
Exit codes: 0 always when git works (the list may be empty), 2 when git
13+
cannot read BASE.
14+
"""
15+
from __future__ import annotations
16+
17+
import subprocess
18+
import sys
19+
20+
ROOTS = ("skills/", "live-session/skills/")
21+
22+
23+
def names(paths: list[str]) -> set[str]:
24+
out = set()
25+
for p in paths:
26+
for root in ROOTS:
27+
rest = p[len(root):] if p.startswith(root) else None
28+
if rest and rest.count("/") == 1 and rest.endswith("/SKILL.md"):
29+
out.add("skill " + rest.split("/")[0])
30+
if p.startswith("rules/") and p.endswith(".mdc") and p.count("/") == 1:
31+
out.add("rule " + p[len("rules/"):-len(".mdc")])
32+
return out
33+
34+
35+
def tree(ref: str) -> list[str]:
36+
return subprocess.run(["git", "ls-tree", "-r", "--name-only", ref, "--", "skills", "rules",
37+
"live-session/skills"],
38+
capture_output=True, text=True, check=True).stdout.splitlines()
39+
40+
41+
def main(argv: list[str]) -> int:
42+
if len(argv) != 2:
43+
print("usage: removed_names.py BASE", file=sys.stderr)
44+
return 2
45+
try:
46+
before, after = names(tree(argv[1])), names(tree("HEAD"))
47+
except subprocess.CalledProcessError as e:
48+
print(f"git could not read {argv[1]}: {e.stderr.strip()}", file=sys.stderr)
49+
return 2
50+
for name in sorted(before - after):
51+
print(name)
52+
return 0
53+
54+
55+
if __name__ == "__main__":
56+
sys.exit(main(sys.argv))

‎.github/workflows/release.yml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,21 @@ jobs:
9191
bump="patch"; release="true"
9292
fi
9393
94+
# Skill and rule names are what agents and docs cite: dropping one is
95+
# breaking after 1.0 (ROADMAP "Stable (1.0)", #498). Before 1.0 a
96+
# breaking marker would cut 1.0.0 by accident, so it only warns.
97+
if [ -n "$last_tag" ] && [ "$release" = "true" ]; then
98+
removed=$(python3 .github/scripts/removed_names.py "$last_tag")
99+
if [ -n "$removed" ] && [ "$bump" != "major" ]; then
100+
msg="skill/rule names removed since $last_tag without a breaking-change marker: $(echo "$removed" | paste -sd, -)"
101+
if [ "$(cut -d. -f1 VERSION)" -ge 1 ]; then
102+
echo "::error::$msg. Use feat!:/fix!: or a BREAKING CHANGE: footer"
103+
exit 1
104+
fi
105+
echo "::warning::$msg (allowed before 1.0; from 1.0 this fails the release)"
106+
fi
107+
fi
108+
94109
echo "bump=$bump" >> "$GITHUB_OUTPUT"
95110
echo "release=$release" >> "$GITHUB_OUTPUT"
96111
if [ "$release" = "true" ]; then

‎.github/workflows/validate.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,9 @@ jobs:
487487
- name: Exit-code gate resolves fail() helpers and tuple returns
488488
run: python3 tests/test_exit_code_readme.py -v
489489

490+
- name: Removed skill/rule names are detected for the release guard (#498)
491+
run: python3 tests/test_removed_names.py -v
492+
490493
- name: Showcase falsifier sweep shards cover every piece once
491494
run: python3 tests/test_falsifier_shard.py -v
492495

‎tests/test_removed_names.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Tests for .github/scripts/removed_names.py (#498). Needs git.
2+
3+
Run: python tests/test_removed_names.py -v
4+
"""
5+
from __future__ import annotations
6+
7+
import shutil
8+
import subprocess
9+
import sys
10+
import tempfile
11+
import unittest
12+
from pathlib import Path
13+
14+
SCRIPT = Path(__file__).resolve().parent.parent / ".github" / "scripts" / "removed_names.py"
15+
16+
17+
def git(cwd, *args):
18+
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
19+
20+
21+
@unittest.skipIf(shutil.which("git") is None, "git not available")
22+
class RemovedNames(unittest.TestCase):
23+
def setUp(self):
24+
self.tmp = tempfile.TemporaryDirectory()
25+
self.repo = Path(self.tmp.name)
26+
git(self.repo, "init", "-q")
27+
git(self.repo, "config", "user.email", "t@example.com")
28+
git(self.repo, "config", "user.name", "t")
29+
for rel in ("skills/a/SKILL.md", "skills/b/SKILL.md", "rules/r1.mdc",
30+
"live-session/skills/live/SKILL.md", "snippets/s.py"):
31+
self.write(rel)
32+
git(self.repo, "add", "-A")
33+
git(self.repo, "commit", "-q", "-m", "base")
34+
git(self.repo, "tag", "v1")
35+
36+
def tearDown(self):
37+
self.tmp.cleanup()
38+
39+
def write(self, rel, text="x\n"):
40+
p = self.repo / rel
41+
p.parent.mkdir(parents=True, exist_ok=True)
42+
p.write_text(text, encoding="utf-8")
43+
44+
def commit(self):
45+
git(self.repo, "add", "-A")
46+
git(self.repo, "commit", "-q", "-m", "change")
47+
48+
def removed(self):
49+
out = subprocess.run([sys.executable, str(SCRIPT), "v1"], cwd=self.repo,
50+
capture_output=True, text=True, check=True).stdout
51+
return out.split()
52+
53+
def test_nothing_removed(self):
54+
self.write("skills/c/SKILL.md")
55+
self.write("skills/a/SKILL.md", "edited\n")
56+
self.commit()
57+
self.assertEqual(self.removed(), [])
58+
59+
def test_renamed_skill_and_removed_rule(self):
60+
git(self.repo, "mv", "skills/b", "skills/b2")
61+
git(self.repo, "rm", "-q", "rules/r1.mdc")
62+
self.commit()
63+
self.assertEqual(" ".join(self.removed()), "rule r1 skill b")
64+
65+
def test_live_session_skill_counts(self):
66+
git(self.repo, "rm", "-q", "-r", "live-session/skills/live")
67+
self.commit()
68+
self.assertEqual(" ".join(self.removed()), "skill live")
69+
70+
def test_snippet_removal_is_not_a_name_change(self):
71+
git(self.repo, "rm", "-q", "snippets/s.py")
72+
self.commit()
73+
self.assertEqual(self.removed(), [])
74+
75+
76+
if __name__ == "__main__":
77+
unittest.main()

0 commit comments

Comments
 (0)