#!/usr/bin/env python3 """bump-version.py — Atomically bump every parity-locked file to a new version. The repo intentionally keeps per-IDE frontmatter and per-language prose divergent across SKILL.md variants, but the `metadata.version` field (plus the version field in plugin.json, marketplace.json, CITATION.cff) must always agree across the parity set. Past releases (v2.34.1, v2.36.0, v2.36.2, v2.36.3) repeatedly hit "missed one variant" regressions because the bump was done by hand. Run before tagging a release: python scripts/bump-version.py 2.37.0 python scripts/bump-version.py 2.37.0 --dry-run Files touched (parity set, 19 entries): skills/planning-with-files/SKILL.md (canonical) skills/planning-with-files-{ar,de,es,zh,zht}/SKILL.md .{codebuddy,codex,cursor,factory,hermes,mastracode,opencode,pi,kiro}/skills/planning-with-files/SKILL.md clawhub-upload/SKILL.md .claude-plugin/plugin.json .claude-plugin/marketplace.json CITATION.cff Files intentionally left behind (do not bump automatically): .continue/skills/planning-with-files/SKILL.md .gemini/skills/planning-with-files/SKILL.md """ from __future__ import annotations import argparse import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] # (path relative to repo root, kind) # kind: "skill_md", "plugin_json", "marketplace_json", "citation_cff" PARITY_FILES = [ ("skills/planning-with-files/SKILL.md", "skill_md"), ("skills/planning-with-files-ar/SKILL.md", "skill_md"), ("skills/planning-with-files-de/SKILL.md", "skill_md"), ("skills/planning-with-files-es/SKILL.md", "skill_md"), ("skills/planning-with-files-zh/SKILL.md", "skill_md"), ("skills/planning-with-files-zht/SKILL.md", "skill_md"), (".codebuddy/skills/planning-with-files/SKILL.md", "skill_md"), (".codex/skills/planning-with-files/SKILL.md", "skill_md"), (".cursor/skills/planning-with-files/SKILL.md", "skill_md"), (".factory/skills/planning-with-files/SKILL.md", "skill_md"), (".hermes/skills/planning-with-files/SKILL.md", "skill_md"), (".mastracode/skills/planning-with-files/SKILL.md", "skill_md"), (".opencode/skills/planning-with-files/SKILL.md", "skill_md"), ("clawhub-upload/SKILL.md", "skill_md"), (".claude-plugin/plugin.json", "plugin_json"), (".claude-plugin/marketplace.json", "marketplace_json"), ("CITATION.cff", "citation_cff"), ] # Files left behind on purpose. Documented to make the omission explicit. LAGGING_FILES = [ ".continue/skills/planning-with-files/SKILL.md", ".gemini/skills/planning-with-files/SKILL.md", ".pi/skills/planning-with-files/SKILL.md", # npm scheme (1.0.x) ".kiro/skills/planning-with-files/SKILL.md", # kiro scheme (2.32.0-kiro) ] VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-]+)?$") def bump_skill_md(path: Path, new: str, *, dry_run: bool) -> tuple[str | None, str | None]: text = path.read_text(encoding="utf-8") pattern = re.compile(r'(version:\s*")([^"]+)(")') match = pattern.search(text) if not match: return None, f"no version field found in {path}" old = match.group(2) if old == new: return old, None new_text = pattern.sub(rf'\g<1>{new}\g<3>', text, count=1) if not dry_run: path.write_text(new_text, encoding="utf-8") return old, None def bump_plugin_or_marketplace(path: Path, new: str, *, dry_run: bool) -> tuple[str | None, str | None]: """Bump the version field in plugin.json or marketplace.json. Both files keep the canonical plugin version in a single "version": "..." pair, but marketplace.json nests it under plugins[0]. We rewrite every occurrence in the file (there is only one for our manifests), which keeps the script tolerant of either layout. """ text = path.read_text(encoding="utf-8") pattern = re.compile(r'("version"\s*:\s*")([^"]+)(")') matches = list(pattern.finditer(text)) if not matches: return None, f"no version field found in {path}" old = matches[0].group(2) if all(m.group(2) == new for m in matches): return old, None new_text = pattern.sub(rf'\g<1>{new}\g<3>', text) if not dry_run: path.write_text(new_text, encoding="utf-8") return old, None def bump_citation_cff(path: Path, new: str, *, dry_run: bool) -> tuple[str | None, str | None]: text = path.read_text(encoding="utf-8") # CITATION.cff carries `version: "2.36.3"` (quoted) or `version: 2.36.3`. pattern = re.compile(r'^(version:\s*)"?([^"\s#]+)"?(\s*(?:#.*)?)$', re.MULTILINE) match = pattern.search(text) if not match: return None, f"no version field found in {path}" old = match.group(2) if old == new: return old, None new_text = pattern.sub(rf'\g<1>"{new}"\g<3>', text, count=1) if not dry_run: path.write_text(new_text, encoding="utf-8") return old, None HANDLERS = { "skill_md": bump_skill_md, "plugin_json": bump_plugin_or_marketplace, "marketplace_json": bump_plugin_or_marketplace, "citation_cff": bump_citation_cff, } def parse_args(argv=None): p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) p.add_argument("new_version", help="Target semver, e.g. 2.37.0") p.add_argument("--dry-run", action="store_true", help="Preview without writing") return p.parse_args(argv) def main(argv=None) -> int: args = parse_args(argv) new = args.new_version.lstrip("v") if not VERSION_RE.match(new): print(f"Error: '{new}' is not a valid semver string.", file=sys.stderr) return 2 print(f"{'[DRY RUN] ' if args.dry_run else ''}Bumping parity set to {new}\n") failures: list[str] = [] changed = 0 skipped = 0 for rel, kind in PARITY_FILES: path = REPO_ROOT / rel if not path.exists(): failures.append(f"missing: {rel}") print(f" MISSING: {rel}") continue handler = HANDLERS[kind] old, err = handler(path, new, dry_run=args.dry_run) if err: failures.append(err) print(f" ERROR: {rel} ({err})") continue if old == new: skipped += 1 print(f" unchanged: {rel} (already {new})") else: changed += 1 print(f" bumped: {rel} {old} -> {new}") print(f"\nChanged: {changed} Unchanged: {skipped} Errors: {len(failures)}") print(f"Lagging (not auto-bumped): {len(LAGGING_FILES)}") for rel in LAGGING_FILES: print(f" -> {rel}") if failures: print("\nFAILED:", *failures, sep="\n ", file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main())