Skip to content

Commit bfc0fc9

Browse files
authored
feat: add version metadata, consistency checker, and portability self-test (#4)
what: add version fields to SKILL.md and CITATION.cff, create version consistency checker, wire into CI, add --self-test to check-portability why: version tracking enables release management and aligns with companion repo conventions; --self-test makes portability validator easier to maintain - Add version: '0.1.0' to SKILL.md frontmatter - Add version: '0.1.0' to CITATION.cff, remove personal email - Create scripts/check-version-consistency.py (aligned, drift, no-tag states) - Add version consistency check to CI and validate-ci.py required commands - Add --self-test mode to .github/scripts/check-portability.py (15 test cases) - Require version field in validate.py frontmatter parsing
1 parent cf393aa commit bfc0fc9

7 files changed

Lines changed: 210 additions & 3 deletions

File tree

.github/scripts/check-portability.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
FORBIDDEN_PATTERNS = (
1717
("Hermes tool name", re.compile(r"\bskill_(?:view|manage)\b", re.IGNORECASE)),
18-
("Hermes config path", re.compile(r"~/\\.hermes(?:/|\b)", re.IGNORECASE)),
18+
("Hermes config path", re.compile(r"~/\.hermes(?:/|\b)", re.IGNORECASE)),
1919
(
2020
"Hermes CLI command",
2121
re.compile(
@@ -53,7 +53,54 @@ def scan_file(path: Path) -> list[tuple[Path, int, str, str]]:
5353
return violations
5454

5555

56+
def run_self_tests() -> int:
57+
"""Run internal self-tests for the validation logic."""
58+
test_cases = [
59+
("skill_view(name)", True, "Hermes tool name"),
60+
("SKILL_VIEW(name)", True, "Hermes tool name case insensitive"),
61+
("from hermes_tools import foo", True, "Hermes Python import"),
62+
("from HERMES_TOOLS import foo", True, "Hermes Python import case insensitive"),
63+
("~/.hermes/config", True, "Hermes config path"),
64+
("~/.hermes/", True, "Hermes config path with slash"),
65+
("hermes skills install", True, "Hermes CLI command"),
66+
("hermes config set", True, "Hermes CLI command"),
67+
("Claude()", True, "Claude Code agent reference"),
68+
("gemini skills", True, "Gemini CLI command"),
69+
("codex run", True, "Codex CLI command"),
70+
(".claude/config", True, "Platform-specific path"),
71+
(".cursor/config", True, "Platform-specific path"),
72+
("normal text", False, "Normal text"),
73+
("skill_manage(action='create')", True, "Hermes tool name"),
74+
]
75+
76+
for text, should_match, label in test_cases:
77+
for pattern_label, pattern in FORBIDDEN_PATTERNS:
78+
if pattern.search(text):
79+
if not should_match:
80+
print(f"FAIL: {label} matched unexpectedly: {text!r}")
81+
return 1
82+
break
83+
else:
84+
if should_match:
85+
print(f"FAIL: {label} should have matched: {text!r}")
86+
return 1
87+
88+
print("PASS: check-portability.py self-tests")
89+
return 0
90+
91+
5692
def main() -> int:
93+
import argparse
94+
95+
parser = argparse.ArgumentParser(
96+
description="Check for agent-specific references in portable skill files."
97+
)
98+
parser.add_argument("--self-test", action="store_true", help="Run internal self-tests")
99+
args = parser.parse_args()
100+
101+
if args.self_test:
102+
return run_self_tests()
103+
57104
violations: list[tuple[Path, int, str, str]] = []
58105
for path in sorted(SKILLS_DIR.rglob("*.md")):
59106
try:
@@ -73,4 +120,4 @@ def main() -> int:
73120

74121

75122
if __name__ == "__main__":
76-
sys.exit(main())
123+
sys.exit(main())

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ jobs:
5757
- name: Check cross-agent portability
5858
run: python3 .github/scripts/check-portability.py
5959

60+
- name: Check version consistency
61+
run: python3 scripts/check-version-consistency.py
62+
6063
- name: Validate CI policy
6164
run: python3 scripts/validate-ci.py
6265

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ cff-version: 1.2.0
22
message: "If you use this work in your research, please cite it as below."
33
authors:
44
- family-names: "CodeSigils"
5-
email: "toolsoftrade.web@gmail.com"
65
title: "Python Project Workflow"
6+
version: "0.1.0"
77
license: MIT
88
type: software
99
repository-code: https://github.com/CodeSigils/python-project-workflow-skill
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
#!/usr/bin/env python3
2+
"""Check local release versions and git tags for consistency."""
3+
4+
from __future__ import annotations
5+
6+
import re
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
11+
12+
ROOT = Path(__file__).resolve().parents[1]
13+
14+
LOCAL_VERSION_SOURCES: dict[str, Path] = {
15+
"SKILL.md": ROOT / "skills" / "python-project-workflow" / "SKILL.md",
16+
"CITATION.cff": ROOT / "CITATION.cff",
17+
}
18+
19+
20+
def normalize_version(version: str) -> str:
21+
return version.removeprefix("v")
22+
23+
24+
def read_skill_version(path: Path) -> str | None:
25+
"""Extract metadata version from SKILL.md frontmatter."""
26+
try:
27+
content = path.read_text(encoding="utf-8")
28+
except OSError:
29+
return None
30+
frontmatter = content.split("---", 2)
31+
if len(frontmatter) < 3:
32+
return None
33+
match = re.search(r'(?m)^version:\s*["\']?([^"\'#\s]+)', frontmatter[1])
34+
return match.group(1) if match else None
35+
36+
37+
def read_citation_version(path: Path) -> str | None:
38+
"""Extract the version field from CITATION.cff."""
39+
try:
40+
content = path.read_text(encoding="utf-8")
41+
except OSError:
42+
return None
43+
match = re.search(r'(?m)^version:\s*["\']?([^"\'#\s]+)', content)
44+
return match.group(1) if match else None
45+
46+
47+
def get_latest_tag() -> tuple[str | None, str | None]:
48+
"""Return the latest v-prefixed tag and an optional query error."""
49+
try:
50+
result = subprocess.run(
51+
["git", "tag", "--list", "v*", "--sort=-version:refname"],
52+
capture_output=True,
53+
text=True,
54+
check=False,
55+
)
56+
except OSError as exc:
57+
return None, str(exc)
58+
if result.returncode != 0:
59+
return None, result.stderr.strip() or f"git exited {result.returncode}"
60+
tags = result.stdout.splitlines()
61+
return (tags[0], None) if tags else (None, None)
62+
63+
64+
def validate_versions(
65+
local_versions: dict[str, str | None],
66+
latest_tag: str | None,
67+
tag_error: str | None,
68+
) -> tuple[list[str], list[str]]:
69+
"""Validate version consistency and return errors plus informational notes."""
70+
errors: list[str] = []
71+
notes: list[str] = []
72+
comparable: dict[str, str] = {}
73+
74+
for source in LOCAL_VERSION_SOURCES:
75+
version = local_versions.get(source)
76+
if not version:
77+
errors.append(f"Could not extract version from {source}")
78+
else:
79+
comparable[source] = normalize_version(version)
80+
81+
if tag_error:
82+
notes.append(f"SKIP: could not query git tags: {tag_error}")
83+
elif latest_tag:
84+
comparable["latest tag"] = normalize_version(latest_tag)
85+
else:
86+
notes.append("SKIP: no v-prefixed git tags found")
87+
88+
if comparable and len(set(comparable.values())) > 1:
89+
rendered = ", ".join(
90+
f"{source}={version}" for source, version in comparable.items()
91+
)
92+
errors.append(f"Version drift: {rendered}")
93+
94+
return errors, notes
95+
96+
97+
def run_self_tests() -> int:
98+
"""Cover aligned, drifted, and no-tag states."""
99+
aligned = {source: "0.1.0" for source in LOCAL_VERSION_SOURCES}
100+
101+
errors, notes = validate_versions(aligned, "v0.1.0", None)
102+
assert errors == [] and notes == []
103+
104+
drifted = {**aligned, "CITATION.cff": "0.2.0"}
105+
errors, _ = validate_versions(drifted, "v0.1.0", None)
106+
assert any(error.startswith("Version drift:") for error in errors)
107+
108+
errors, notes = validate_versions(aligned, None, None)
109+
assert errors == [] and notes == ["SKIP: no v-prefixed git tags found"]
110+
111+
errors, notes = validate_versions(aligned, None, "not a git repository")
112+
assert errors == [] and notes == [
113+
"SKIP: could not query git tags: not a git repository"
114+
]
115+
116+
print("PASS: check-version-consistency.py self-tests")
117+
return 0
118+
119+
120+
def main() -> int:
121+
import argparse
122+
123+
parser = argparse.ArgumentParser(description=__doc__)
124+
parser.add_argument("--self-test", action="store_true")
125+
args = parser.parse_args()
126+
127+
if args.self_test:
128+
return run_self_tests()
129+
130+
local_versions = {
131+
"SKILL.md": read_skill_version(LOCAL_VERSION_SOURCES["SKILL.md"]),
132+
"CITATION.cff": read_citation_version(LOCAL_VERSION_SOURCES["CITATION.cff"]),
133+
}
134+
latest_tag, tag_error = get_latest_tag()
135+
136+
for source, version in local_versions.items():
137+
print(f"{source} version: {version or 'unreadable'}")
138+
print(f"Latest tag: {latest_tag or 'none'}")
139+
140+
errors, notes = validate_versions(local_versions, latest_tag, tag_error)
141+
for note in notes:
142+
print(note)
143+
if errors:
144+
for error in errors:
145+
print(f"ERROR: {error}", file=sys.stderr)
146+
return 1
147+
148+
print("OK: all available version sources are consistent")
149+
return 0
150+
151+
152+
if __name__ == "__main__":
153+
sys.exit(main())

scripts/validate-ci.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
SHA_PIN_RE = re.compile(r"^[^@\s]+@[0-9a-f]{40}$")
1414
REQUIRED_VALIDATE_COMMANDS = (
1515
"python3 .github/scripts/check-portability.py",
16+
"python3 scripts/check-version-consistency.py",
1617
"python3 scripts/validate-ci.py",
1718
"python3 scripts/validate.py",
1819
"python3 scripts/test-validate-ci.py",

scripts/validate.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
9393
fail(f"unsupported frontmatter fields: {sorted(extra)}")
9494
if data.get("name") != "python-project-workflow":
9595
fail("frontmatter name must be python-project-workflow")
96+
if not data.get("version"):
97+
fail("frontmatter must include a version field")
9698
if len(data.get("description", "").split()) < 18:
9799
fail("frontmatter description is too short to trigger reliably")
98100
return data, body

skills/python-project-workflow/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
name: python-project-workflow
3+
version: "0.1.0"
34
description: Set up, inspect, preserve, and verify Python projects across greenfield bootstrap, tooling configuration, CI, packaging, mature-repo preservation, and project-native verification. Use for Python project workflow tasks, not for pure code review; use py-review-skill or another dedicated review skill for review findings.
45
---
56

0 commit comments

Comments
 (0)