Files
planning-with-files/scripts/bump-version.py
T
OthmanAdi 6cd6254b23 release: v2.37.0 (hash attestation + parity-locked version bumper)
Closes #150 (hash attestation): adds an opt-in second layer of defense for
plan injection. After v2.36.1 wrapped task_plan.md content in BEGIN/END
delimiters, the model still parsed the bytes. v2.37.0 adds /plan-attest, a
slash command that locks the current task_plan.md content with a SHA-256
stored at .planning/<active-plan>/.attestation (parallel-plan mode) or
./.plan-attestation (legacy mode). UserPromptSubmit and PreToolUse hooks
recompute the hash on every fire and emit "[PLAN TAMPERED — injection
blocked]" instead of plan content on mismatch. When attestation is set, the
injected context also carries a Plan-SHA256: line for audit. New helper:
scripts/attest-plan.sh and .ps1 with --show / --clear modes, propagated to
all six IDE mirrors that ship scripts.

Closes #151 (parity-locked version bumper): names the regression class
behind v2.34.1, v2.36.0, v2.36.2, v2.36.3 (one of N parity-locked files
forgotten on bump). Fixes it surgically without overshooting into
"regenerate all SKILL.md from canonical" (per-IDE frontmatter and per-
language prose are intentionally divergent). New
scripts/bump-version.py atomically bumps the parity set in one shot. New
tests/test_skill_md_version_parity.py fails the build the moment any
parity-locked file diverges from the canonical version. .continue,
.gemini, .pi, .kiro stay on their separate schemes and are surfaced in
every bump-version run so the omission is explicit.

Other changes:
- tests/test_canonical_script_sync.py: removed leftover duplicate
  CanonicalScriptSyncTests class block (lines 99 to 137); SHARED_SCRIPTS
  extended to ten entries for attest-plan.{sh,ps1}.
- scripts/sync-ide-folders.py: SCRIPTS manifest extended to cover
  attest-plan helpers; existing eight scripts unchanged.
- canonical SKILL.md Security Boundary section: documents the two layers
  of defense and the /plan-attest workflow.
- 14 SKILL.md variants + plugin.json + marketplace.json + CITATION.cff
  bumped to 2.37.0 via scripts/bump-version.py (.continue / .gemini /
  .pi / .kiro left on their own schemes per release scope rule).

Tests: 78 pass, 2 pre-existing Windows exec-bit failures unchanged.
2026-05-05 17:59:51 +02:00

184 lines
6.6 KiB
Python

#!/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())