
Release
- 40 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Cut a software release and maintain a tiered compatibility policy: bump versions, run a readiness gate, update COMPATIBILITY and changelog, and tag.
About
A config-driven release orchestrator that enforces SemVer against declared stable surfaces, runs a readiness gate, drafts a Keep-a-Changelog section, and tags the release. A developer uses it to ship a version with compatibility tiers and deprecations tracked.
- Tiered surfaces (experimental/preview/stable) enforce major bumps
- Readiness gate plus dry-run for unfamiliar stacks
Release by the numbers
- 40 all-time installs (skills.sh)
- Ranked #146 of 248 Release Management skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill releaseAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 40 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Cut a software release and maintain a tiered compatibility policy: bump versions, run a readiness gate, update COMPATIBILITY and changelog, and tag.
Files
release — tiered compatibility & release workflow
A config-driven release orchestrator. Mechanics live in scripts/release.py (unit-tested, stdlib-only); this file is the workflow you (or an agent) follow. Read reference/config-schema.md for release.config.json and reference/standards.md for why each step exists.
Scope honesty: reference-tested on a Tauri + Rust + SvelteKit repo (Cull).
Other stacks are supported by config, not yet validated. Treat first runs on
a new stack as a dry run (see "Dry run" below) until you trust it.
When to use
The user says "release", "ship it", "cut a version", "bump version", "tag a release", "update the changelog/COMPATIBILITY". Requires a release.config.json at the repo root (scaffold from templates/release.config.json.tmpl).
Mental model — your public API is your declared surfaces
A version only means something once you declare what you promise to keep working. release.config.json → surfaces[] is that declaration; each surface has a tier (experimental → preview → stable) and a compatibility mode. Only stable surfaces carry the promise. Breaking a stable surface forces a major bump — the engine enforces this. (Standards: see reference/standards.md.)
Command
/release <patch|minor|major> — run the steps below. On an unfamiliar repo, do a dry run first (see below). When the user asks to "explain", expand each step's why into a short lesson from reference/standards.md.
Steps
1. Preconditions. The configured releaseBranch (default main, in worktree if set) is checked out, clean, and synced with origin. Abort clearly otherwise. — why: a release tag must point at a known-good, pushed tree.
2. Version. python3 scripts/release.py --config <cfg> plan <kind> prints the new version + tag. It asserts the version files currently agree. Show old → new. — why: SemVer math; 0.x lets minors break (pre-1.0). [[Semantic Versioning (SemVer)]]
3. Readiness gate. Run cfg.gate then each cfg.extraGate[]. All must exit 0 (fmt, clippy, tests, license audit, prod build, golden contract tests). Block on failure. — why: this is a Production-Readiness Review. [[Production Readiness Review]]
- TODO (deferred): cargo-deny / cargo-audit / SBOM. List, don't enforce yet.
4. Changelog. Collect commit subjects since the last tag (git log <lastTag>..HEAD --format=%s) and draft a section — the engine's draft_changelog buckets them into Added/Changed/Fixed (Keep a Changelog). Insert under the top of CHANGELOG.md; hand-curate the user-facing lines. — why: humans read changelogs; conventional commits seed them. [[Keep a Changelog]]
5. Compatibility review. Open cfg.compatibility.path (COMPATIBILITY.md). Ask:
- Did any surface change tier? (update the Surfaces table)
- New deprecations? (add a row: item / deprecated-in / removable-in / replacement)
- Does this change break a `stable` surface? If yes, the required bump is
major — re-run with major or the release is invalid. (Enforced by enforce_bump.) Stamp "Last updated: <new version> (<date>)". — why: tiers + deprecation windows are how you evolve without lying. [[Kubernetes API Deprecation Policy]]
6. Bump & commit. python3 scripts/release.py --config <cfg> bump <kind> writes every version file; refresh cfg.lockfiles (e.g. cargo update -p <crate> or a build). Commit chore(release): v<new> including CHANGELOG + COMPATIBILITY.
7. Tag & push. git tag v<new> and push the tag (→ the repo's release workflow) and the branch. Confirm the tag trigger exists before the first release (grep -A3 '^on:' .github/workflows/*.yml).
8. Report. Print the tag, the release-workflow URL, and issues closed since the last tag (if cfg.issueTracker is set, e.g. bd) — those are the release notes.
Dry run
There is no --dry-run flag — a dry run is steps 1–5 done without mutating: run python3 scripts/release.py --config <cfg> plan <kind> (pure: prints the version/tag, writes nothing) and optionally run cfg.gate to check readiness. Do NOT run bump, commit, or tag. The bump subcommand is the only engine command that writes (version files only); commit/tag/push are git steps you take in step 6–7, never the engine.
Manual fallback (no skill)
plan → run gate → edit CHANGELOG + COMPATIBILITY → bump → commit → tag → push. The engine is just scripts/release.py; everything else is git.
Growing into Contracts & Modes
The readiness gate runs golden/contract tests (cfg.extraGate). Start with one (a DB round-trip), then add export and API contract tests. See the consuming repo's docs/CONTRACTS.md and reference/standards.md. [[Pact — Consumer-Driven Contract Testing]]
release
A config-driven Claude Code skill that cuts software releases and maintains a tiered compatibility policy. It bumps version files, runs a readiness gate, updates a COMPATIBILITY.md (surfaces × tiers + deprecations), tags the release (triggering your CI release workflow), and teaches the underlying standards as it runs.
Scope (honest)
Reference-tested on a Tauri 2 + Rust + SvelteKit desktop app (Cull). It is generic by config (release.config.json), but other stacks are not yet validated — on a new repo, do a dry run first (scripts/release.py plan <kind>; see SKILL.md).
Install
This skill lives in `glebis/claude-skills`. With the repo on your Claude Code skills path, invoke it as /release.
Use
/release <patch|minor|major>Requires a release.config.json at the repo root. Scaffold one from templates/release.config.json.tmpl. Full field reference: `reference/config-schema.md`.
The engine (scripts/release.py) is stdlib-only Python and independently runnable:
python3 scripts/release.py --config release.config.json plan minor # preview
python3 scripts/release.py --config release.config.json bump minor # write versions
python3 -m unittest test_release -v # 22 testsWhat it does, and why
The flow and its rationale are in `SKILL.md`. The standards it encodes — SemVer, Go 1 compatibility promise, Kubernetes deprecation policy, SRE Production-Readiness Review, Schema-Registry compatibility modes, Pact, Keep a Changelog, RFC 9745/8594, MCP protocolVersion — are summarized with links in `reference/standards.md`.
Files
| Path | Purpose |
|---|---|
SKILL.md | the /release workflow + --explain lessons |
scripts/release.py | the engine (pure functions + plan/bump CLI) |
scripts/test_release.py | 22 unit tests |
reference/config-schema.md | release.config.json reference |
reference/standards.md | the standards map + links |
reference/compatibility-md.md | how COMPATIBILITY.md is structured/updated |
templates/*.tmpl | scaffolds for config, COMPATIBILITY, CONTRACTS |
COMPATIBILITY.md structure & upkeep
The living contract. The skill updates it at step 5 of every release.
Sections
1. Promise — prose, Go-1 style: what X.y.z guarantees, what forces a major. 2. Surfaces table — the declared public API:
| Surface | Tier | Since | Mode | Notes |
|---|---|---|---|---|
| Database schema | stable | 0.1.0 | BACKWARD_TRANSITIVE | migrations additive-only |
| MCP token API | preview | — | unversioned | no version handshake yet → may change |
| Export formats | stable | 0.1.0 | forward-compatible | unknown fields ignored |
3. Deprecations table — nothing is removed without a window:
| Item | Deprecated in | Removable in | Replacement |
|---|
4. 1.0 readiness gate — the checklist that defines "stable enough to promise".
Update rules (per release)
- Tier change? edit the surface row + note the version.
- New deprecation? add a row (item / deprecated-in / removable-in / replacement);
signal on the wire with RFC 9745 / RFC 8594 where applicable.
- Breaking a `stable` surface? the release MUST be a
major(the engine
enforces it). Prefer keeping young surfaces at preview.
- Always stamp
Last updated: <version> (<date>).
Promotion preview → stable happens only when that surface's 1.0-gate items pass (e.g. MCP gains a protocolVersion handshake + a deprecation policy).
release.config.json reference
A single JSON file at the repo root declaring how to release this project and what its public API surfaces are.
{
// Files whose version string must stay in sync. JSON uses a pointer; TOML a dotted key.
"versionFiles": [
{ "path": "package.json", "kind": "json", "pointer": "/version" },
{ "path": "src-tauri/tauri.conf.json", "kind": "json", "pointer": "/version" },
{ "path": "src-tauri/Cargo.toml", "kind": "toml", "key": "package.version" }
],
// Lockfiles to refresh after a bump (the skill reminds you / you wire the command).
"lockfiles": ["src-tauri/Cargo.lock"],
// Readiness gate: a single shell command that must exit 0.
"gate": "npm run preflight -- release",
// Extra gate commands (golden / contract tests). All must exit 0.
"extraGate": [
"cargo test --manifest-path src-tauri/Cargo.toml --features test-support --test compat_golden"
],
// Changelog.
"changelog": { "path": "CHANGELOG.md", "style": "keep-a-changelog", "from": "conventional-commits" },
// The living compatibility doc.
"compatibility": { "path": "docs/COMPATIBILITY.md" },
// The declared PUBLIC API. tier ∈ {experimental, preview, stable}.
// Only `stable` surfaces carry the compatibility promise; breaking one forces a major bump.
"surfaces": [
{ "id": "db", "name": "Database schema", "tier": "stable", "mode": "BACKWARD_TRANSITIVE" },
{ "id": "mcp", "name": "MCP token API", "tier": "preview", "mode": "unversioned" },
{ "id": "exports", "name": "Export formats", "tier": "stable", "mode": "forward-compatible" }
],
// Branch releases are cut from, and (optionally) the worktree where it's checked out.
"releaseBranch": "main",
"worktree": "../cull-main-landing",
// Tagging. The tag (e.g. v0.2.0) should trigger your CI release workflow.
"tag": { "prefix": "v", "push": true },
// Optional: release-notes source.
"issueTracker": { "kind": "bd", "binEnv": "BD_BIN" }
}Field notes
- `kind: "json"` needs
pointer(RFC-6901-ish slash path, e.g./version).
`kind: "toml"` needs key (section.name, e.g. package.version). The rewrite is targeted (preserves formatting and unrelated keys).
- `gate` is your existing release-tier check (tests + lint + license + build).
Keep CVE/SBOM here once you add cargo-deny/cargo-cyclonedx.
- `surfaces[].mode` is documentation today (e.g.
BACKWARD_TRANSITIVE,
forward-compatible, unversioned); it becomes enforceable as you add contract tests to extraGate. See reference/standards.md.
- `tier` drives the gate: a
breakingchange to astablesurface ⇒major.
Keep risky/young surfaces at preview so you can evolve them within minors.
Standards this skill encodes
The workflow isn't invented — each step maps to an industry standard. Learn the one behind whatever step you're on.
| Concern | Standard | Link |
|---|---|---|
| Version math; "declare your public API" | Semantic Versioning 2.0.0 | https://semver.org/spec/v2.0.0.html |
| A single durable compatibility promise (formulation #1) | Go 1 Compatibility Promise | https://go.dev/doc/go1compat |
| Maturity tiers + deprecation windows (formulation #2) | Kubernetes API deprecation policy | https://kubernetes.io/docs/reference/using-api/deprecation-policy/ |
| The release-readiness gate | Google SRE Production-Readiness Review | https://sre.google/sre-book/evolving-sre-engagement-model/ |
| Named compatibility modes (formulation #3) | Schema-Registry compatibility (Avro/Protobuf) | https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html |
| Consumer-driven contract tests | Pact · Fowler | https://docs.pact.io/ · https://martinfowler.com/articles/consumerDrivenContracts.html |
| Human changelog | Keep a Changelog | https://keepachangelog.com/ |
| Wire-level deprecation signalling | RFC 9745 (Deprecation) · RFC 8594 (Sunset) | https://www.rfc-editor.org/rfc/rfc9745.html · https://www.rfc-editor.org/rfc/rfc8594.html |
| API version negotiation (MCP servers) | MCP `protocolVersion` | https://modelcontextprotocol.io/specification/versioning |
The three formulations (this skill = "compose them")
1. The Promise — one prose pledge (Go 1). Enforced by discipline. 2. Tiers & Gates — per-surface tiers + readiness gate + deprecation windows (Kubernetes + SRE). This skill's default posture. 3. Contracts & Modes — named modes enforced by tests in CI (Schema-Registry + Pact). Grown into via `extraGate` golden/contract tests.
Backward-compatible = new code reads old data. Forward-compatible = old code ignores unknown new fields. _TRANSITIVE modes check against all prior versions, not just the last.
#!/usr/bin/env python3
"""Config-driven release engine (stdlib only).
Pure functions (parse/bump/changelog/surface-gate/config/plan) are unit-tested
in test_release.py. The CLI wraps them with side effects (reading/writing files,
git). See ../SKILL.md for the full /release orchestration and teaching layer.
"""
from __future__ import annotations
import argparse
import json
import re
from dataclasses import dataclass
from pathlib import Path
class ReleaseError(Exception):
"""User-facing, recoverable error (printed; CLI exits non-zero)."""
@dataclass(frozen=True)
class Version:
major: int
minor: int
patch: int
def __str__(self) -> str:
return f"{self.major}.{self.minor}.{self.patch}"
# --- SemVer ----------------------------------------------------------------
_SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
def parse_version(s: str) -> Version:
m = _SEMVER.match(s.strip())
if not m:
raise ReleaseError(f"not a SemVer x.y.z: {s!r}")
return Version(int(m[1]), int(m[2]), int(m[3]))
def bump(v: Version, kind: str) -> Version:
if kind == "major":
return Version(v.major + 1, 0, 0)
if kind == "minor":
return Version(v.major, v.minor + 1, 0)
if kind == "patch":
return Version(v.major, v.minor, v.patch + 1)
raise ReleaseError(f"bump kind must be major|minor|patch, got {kind!r}")
# --- version files (JSON pointer / TOML key) -------------------------------
try: # read-only; present on 3.11+
import tomllib
except ModuleNotFoundError: # pragma: no cover
tomllib = None
def read_version_file(path, kind: str, *, pointer=None, key=None) -> str:
text = Path(path).read_text()
if kind == "json":
node = json.loads(text)
for part in [p for p in (pointer or "").split("/") if p]:
if not isinstance(node, dict) or part not in node:
raise ReleaseError(f"JSON pointer {pointer} not found in {path}")
node = node[part]
return str(node)
if kind == "toml":
sect, name = key.split(".", 1)
if tomllib:
data = tomllib.loads(text)
try:
return str(data[sect][name])
except KeyError:
raise ReleaseError(f"no {key} in {path}")
m = re.search(
rf'(?ms)^\[{re.escape(sect)}\].*?^{re.escape(name)}\s*=\s*"([^"]+)"', text
)
if not m:
raise ReleaseError(f"no {key} in {path}")
return m[1]
raise ReleaseError(f"unknown version-file kind {kind!r}")
def write_version_file(path, kind: str, new: str, *, pointer=None, key=None) -> None:
# Match on key AND the *current* value, and require exactly one occurrence in
# the whole file. This refuses to guess (and corrupt) when a same-named key
# exists elsewhere — it errors loudly instead. Preserves file formatting.
path = Path(path)
old = read_version_file(path, kind, pointer=pointer, key=key)
if old == new:
return # idempotent
text = path.read_text()
if kind == "json":
leaf = [p for p in (pointer or "").split("/") if p][-1]
pat = rf'("{re.escape(leaf)}"\s*:\s*)"{re.escape(old)}"'
new_text, n = re.subn(pat, rf'\g<1>"{new}"', text)
if n != 1:
raise ReleaseError(
f'expected exactly one "{leaf}": "{old}" in {path}, found {n} '
f"(ambiguous — refusing to rewrite)"
)
path.write_text(new_text)
return
if kind == "toml":
sect, name = key.split(".", 1)
# `[^\[]*?` refuses to cross into another [section] header, so the match
# is structurally confined to the target section.
pat = rf'(?ms)(^\[{re.escape(sect)}\][^\[]*?^{re.escape(name)}\s*=\s*)"{re.escape(old)}"'
new_text, n = re.subn(pat, rf'\g<1>"{new}"', text, count=1)
if n != 1:
raise ReleaseError(f"could not rewrite {key}={old!r} in {path} (found {n})")
path.write_text(new_text)
return
raise ReleaseError(f"unknown version-file kind {kind!r}")
# --- changelog -------------------------------------------------------------
_CC = re.compile(r"^(?P<type>\w+)(?:\([^)]*\))?(?P<bang>!)?:\s*(?P<desc>.+)$")
_BUCKETS = {"feat": "Added", "fix": "Fixed", "perf": "Changed", "refactor": "Changed"}
_SKIP = {"chore", "docs", "test", "ci", "style", "build"}
def draft_changelog(version: str, date: str, commit_subjects) -> str:
groups: dict[str, list[str]] = {}
for s in commit_subjects:
m = _CC.match(s.strip())
if not m:
continue
t = m["type"]
if t in _SKIP and not m["bang"]:
continue
section = "Changed" if m["bang"] else _BUCKETS.get(t, "Changed")
groups.setdefault(section, []).append(m["desc"].strip())
out = [f"## [{version}] - {date}", ""]
for section in ("Added", "Changed", "Fixed"):
items = groups.get(section)
if not items:
continue
out.append(f"### {section}")
out += [f"- {i}" for i in items]
out.append("")
return "\n".join(out).rstrip() + "\n"
# --- surface-tier gate -----------------------------------------------------
_ORDER = {"patch": 0, "minor": 1, "major": 2}
def required_bump(surfaces_changed) -> str:
req = "patch"
for s in surfaces_changed:
if s.get("breaking") and s.get("tier") == "stable":
return "major"
# any declared change to a surface is at least a minor
if _ORDER[req] < _ORDER["minor"]:
req = "minor"
return req
def enforce_bump(requested: str, required: str) -> None:
if _ORDER[requested] < _ORDER[required]:
raise ReleaseError(
f"requested '{requested}' but a {required} bump is required "
f"(a stable surface changed incompatibly). See COMPATIBILITY.md."
)
# --- config + plan ---------------------------------------------------------
_TIERS = {"experimental", "preview", "stable"}
def load_config(path) -> dict:
cfg = json.loads(Path(path).read_text())
for req in ("versionFiles", "gate", "compatibility", "surfaces", "tag"):
if req not in cfg:
raise ReleaseError(f"release.config.json missing '{req}'")
if not cfg["versionFiles"]:
raise ReleaseError("versionFiles must be non-empty")
for vf in cfg["versionFiles"]:
if "path" not in vf or "kind" not in vf:
raise ReleaseError("each versionFile needs 'path' and 'kind'")
if vf["kind"] not in ("json", "toml"):
raise ReleaseError(f"versionFile kind must be json|toml, got {vf['kind']!r}")
if vf["kind"] == "json" and not vf.get("pointer"):
raise ReleaseError(f"versionFile {vf['path']!r}: json kind requires 'pointer'")
if vf["kind"] == "toml" and not vf.get("key"):
raise ReleaseError(f"versionFile {vf['path']!r}: toml kind requires 'key'")
for s in cfg["surfaces"]:
if s.get("tier") not in _TIERS:
raise ReleaseError(
f"surface {s.get('id')!r}: tier must be one of {sorted(_TIERS)}"
)
return cfg
def build_plan(cfg: dict, current: str, kind: str) -> dict:
new = str(bump(parse_version(current), kind))
prefix = cfg.get("tag", {}).get("prefix", "v")
return {
"new_version": new,
"tag": f"{prefix}{new}",
"files": [vf["path"] for vf in cfg["versionFiles"]],
}
# --- CLI -------------------------------------------------------------------
def _current_version(cfg: dict) -> str:
vf = cfg["versionFiles"][0]
return read_version_file(
Path(vf["path"]), vf["kind"], pointer=vf.get("pointer"), key=vf.get("key")
)
def _assert_versions_agree(cfg: dict) -> str:
seen = {}
for vf in cfg["versionFiles"]:
seen[vf["path"]] = read_version_file(
Path(vf["path"]), vf["kind"], pointer=vf.get("pointer"), key=vf.get("key")
)
distinct = set(seen.values())
if len(distinct) != 1:
raise ReleaseError(f"version files disagree before bump: {seen}")
return distinct.pop()
def _cmd_plan(args):
cfg = load_config(Path(args.config))
plan = build_plan(cfg, _current_version(cfg), args.kind)
print(json.dumps(plan, indent=2))
def _cmd_bump(args):
"""Write the new version into every versionFile (no git side effects)."""
cfg = load_config(Path(args.config))
cur = _assert_versions_agree(cfg)
new = str(bump(parse_version(cur), args.kind))
for vf in cfg["versionFiles"]:
write_version_file(
Path(vf["path"]), vf["kind"], new, pointer=vf.get("pointer"), key=vf.get("key")
)
print(f"bumped {cur} -> {new} across {len(cfg['versionFiles'])} files")
def main(argv=None):
ap = argparse.ArgumentParser(prog="release")
ap.add_argument("--config", default="release.config.json")
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("plan", help="show the planned version/tag (no mutation)")
p.add_argument("kind", choices=["patch", "minor", "major"])
p.set_defaults(func=_cmd_plan)
b = sub.add_parser("bump", help="write the new version into the version files")
b.add_argument("kind", choices=["patch", "minor", "major"])
b.set_defaults(func=_cmd_bump)
args = ap.parse_args(argv)
try:
args.func(args)
except ReleaseError as e:
print(f"error: {e}")
raise SystemExit(2)
if __name__ == "__main__":
main()
"""Unit tests for the release engine. Stdlib unittest, no external deps.
Run: python3 -m unittest test_release -v
"""
import json
import tempfile
import unittest
from pathlib import Path
from release import (
Version,
parse_version,
bump,
read_version_file,
write_version_file,
draft_changelog,
required_bump,
enforce_bump,
load_config,
build_plan,
ReleaseError,
)
class TestVersion(unittest.TestCase):
def test_parse(self):
self.assertEqual(parse_version("1.2.3"), Version(1, 2, 3))
def test_parse_rejects_junk(self):
with self.assertRaises(ReleaseError):
parse_version("1.2")
def test_bump_patch(self):
self.assertEqual(str(bump(Version(0, 1, 0), "patch")), "0.1.1")
def test_bump_minor_resets_patch(self):
self.assertEqual(str(bump(Version(0, 1, 4), "minor")), "0.2.0")
def test_bump_major_resets(self):
self.assertEqual(str(bump(Version(0, 9, 3), "major")), "1.0.0")
def test_bump_rejects_bad_kind(self):
with self.assertRaises(ReleaseError):
bump(Version(0, 1, 0), "huge")
class TestVersionFiles(unittest.TestCase):
def _tmp(self, name, content):
d = tempfile.mkdtemp()
p = Path(d) / name
p.write_text(content)
return p
def test_json_pointer(self):
p = self._tmp("package.json", '{\n "name": "x",\n "version": "0.1.0"\n}\n')
self.assertEqual(read_version_file(p, "json", pointer="/version"), "0.1.0")
write_version_file(p, "json", "0.2.0", pointer="/version")
self.assertEqual(json.loads(p.read_text())["version"], "0.2.0")
# name preserved
self.assertEqual(json.loads(p.read_text())["name"], "x")
def test_toml_key(self):
p = self._tmp("Cargo.toml", '[package]\nname = "x"\nversion = "0.1.0"\n\n[deps]\nversion = "9.9.9"\n')
self.assertEqual(read_version_file(p, "toml", key="package.version"), "0.1.0")
write_version_file(p, "toml", "0.2.0", key="package.version")
self.assertIn('version = "0.2.0"', p.read_text())
# the unrelated [deps] version must NOT change
self.assertIn('[deps]\nversion = "9.9.9"', p.read_text())
def test_missing_field_errors(self):
p = self._tmp("package.json", '{"name":"x"}')
with self.assertRaises(Exception):
read_version_file(p, "json", pointer="/version")
def test_json_refuses_ambiguous_same_name_key(self):
# A sibling object with the SAME key name AND value must not be silently
# rewritten — the engine errors instead of guessing.
p = self._tmp(
"package.json",
'{\n "version": "0.1.0",\n "dep": { "version": "0.1.0" }\n}\n',
)
with self.assertRaises(ReleaseError):
write_version_file(p, "json", "0.2.0", pointer="/version")
def test_toml_does_not_touch_other_section(self):
p = self._tmp(
"Cargo.toml",
'[package]\nname = "x"\nversion = "0.1.0"\n\n[deps]\nversion = "0.1.0"\n',
)
write_version_file(p, "toml", "0.2.0", key="package.version")
txt = p.read_text()
self.assertIn('[package]\nname = "x"\nversion = "0.2.0"', txt)
self.assertIn('[deps]\nversion = "0.1.0"', txt) # untouched even with same value
def test_write_is_idempotent_when_unchanged(self):
p = self._tmp("package.json", '{"version":"0.2.0"}')
write_version_file(p, "json", "0.2.0", pointer="/version") # no error
self.assertEqual(json.loads(p.read_text())["version"], "0.2.0")
class TestChangelog(unittest.TestCase):
def test_buckets_by_type(self):
commits = [
"feat(mcp): add tag scopes",
"fix: stop key leak",
"perf(db): sql folders",
"chore: bump dep",
"docs: tweak",
]
md = draft_changelog("0.2.0", "2026-06-03", commits)
self.assertIn("## [0.2.0] - 2026-06-03", md)
self.assertIn("### Added", md)
self.assertIn("add tag scopes", md)
self.assertIn("### Fixed", md)
self.assertIn("stop key leak", md)
self.assertIn("### Changed", md)
# chores/docs excluded
self.assertNotIn("bump dep", md)
self.assertNotIn("tweak", md)
def test_breaking_bang_goes_to_changed(self):
md = draft_changelog("1.0.0", "2026-06-03", ["feat!: drop old api"])
self.assertIn("### Changed", md)
self.assertIn("drop old api", md)
def test_empty_when_only_chores(self):
md = draft_changelog("0.1.1", "2026-06-03", ["chore: x", "ci: y"])
self.assertIn("## [0.1.1] - 2026-06-03", md)
self.assertNotIn("###", md)
class TestSurfaceGate(unittest.TestCase):
def test_breaking_stable_forces_major(self):
self.assertEqual(
required_bump([{"id": "db", "tier": "stable", "breaking": True}]), "major"
)
def test_breaking_preview_is_minor(self):
self.assertEqual(
required_bump([{"id": "mcp", "tier": "preview", "breaking": True}]), "minor"
)
def test_additive_is_minor(self):
self.assertEqual(
required_bump([{"id": "db", "tier": "stable", "breaking": False}]), "minor"
)
def test_nothing_is_patch(self):
self.assertEqual(required_bump([]), "patch")
def test_enforce_rejects_too_small(self):
with self.assertRaises(ReleaseError):
enforce_bump(requested="patch", required="major")
def test_enforce_allows_equal_or_larger(self):
enforce_bump(requested="major", required="minor") # no raise
enforce_bump(requested="minor", required="minor") # no raise
class TestConfig(unittest.TestCase):
def _cfg(self, obj):
d = tempfile.mkdtemp()
p = Path(d) / "release.config.json"
p.write_text(json.dumps(obj))
return p
def test_loads_and_validates(self):
p = self._cfg({
"versionFiles": [{"path": "package.json", "kind": "json", "pointer": "/version"}],
"gate": "true",
"compatibility": {"path": "docs/COMPATIBILITY.md"},
"surfaces": [{"id": "db", "name": "DB", "tier": "stable", "mode": "BACKWARD_TRANSITIVE"}],
"tag": {"prefix": "v", "push": True},
})
cfg = load_config(p)
self.assertEqual(cfg["gate"], "true")
self.assertEqual(cfg["surfaces"][0]["tier"], "stable")
def test_rejects_bad_tier(self):
p = self._cfg({
"versionFiles": [{"path": "p", "kind": "json", "pointer": "/version"}],
"gate": "true",
"compatibility": {"path": "x"},
"surfaces": [{"id": "db", "name": "DB", "tier": "GA", "mode": "x"}],
"tag": {"prefix": "v"},
})
with self.assertRaises(ReleaseError):
load_config(p)
def test_rejects_missing_key(self):
p = self._cfg({"gate": "true"})
with self.assertRaises(ReleaseError):
load_config(p)
def test_rejects_json_versionfile_without_pointer(self):
p = self._cfg({
"versionFiles": [{"path": "package.json", "kind": "json"}],
"gate": "true", "compatibility": {"path": "x"},
"surfaces": [], "tag": {"prefix": "v"},
})
with self.assertRaises(ReleaseError):
load_config(p)
def test_rejects_unknown_versionfile_kind(self):
p = self._cfg({
"versionFiles": [{"path": "x.yaml", "kind": "yaml", "pointer": "/version"}],
"gate": "true", "compatibility": {"path": "x"},
"surfaces": [], "tag": {"prefix": "v"},
})
with self.assertRaises(ReleaseError):
load_config(p)
class TestPlan(unittest.TestCase):
def test_build_plan(self):
cfg = {
"versionFiles": [{"path": "package.json", "kind": "json", "pointer": "/version"}],
"tag": {"prefix": "v"},
}
plan = build_plan(cfg, current="0.1.0", kind="minor")
self.assertEqual(plan["new_version"], "0.2.0")
self.assertEqual(plan["tag"], "v0.2.0")
self.assertIn("package.json", plan["files"])
if __name__ == "__main__":
unittest.main()
# Compatibility Policy — {{PROJECT}}
{{PROJECT}} follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
This document declares the **public API** — the surfaces we promise to keep
working — and is updated on every release.
**The promise:** within a major version, anything written by an earlier `X.y.z`
keeps working under every later `X.*`. A change that breaks a `stable` surface
requires a new major version. (Modeled on the
[Go 1 Compatibility Promise](https://go.dev/doc/go1compat).)
Tiers: `experimental` (no promise) → `preview` (may change, with notice) →
`stable` (the promise applies).
## Surfaces
| Surface | Tier | Since | Mode | Notes |
|---|---|---|---|---|
| {{SURFACE_NAME}} | preview | — | unversioned | — |
## Deprecations
| Item | Deprecated in | Removable in | Replacement |
|---|---|---|---|
| — | — | — | — |
## 1.0 readiness gate
- [ ] Every surface is `stable` with a declared compatibility mode.
- [ ] Each `stable` surface has golden/contract tests in the release gate.
- [ ] A deprecation process is documented and used.
Last updated: 0.1.0 ({{DATE}})
# Contracts & Modes — {{PROJECT}}
How we make compatibility **mechanically true** instead of merely promised. This
is the third formulation in the release policy (after "The Promise" and "Tiers &
Gates"): declare a compatibility **mode** per surface, then enforce it with tests
that fail the build.
## Vocabulary (from schema registries)
- **Backward** — new code reads old data/messages.
- **Forward** — old code tolerates new data (ignore unknown fields).
- **Full** — both. **`_TRANSITIVE`** — checked against *all* prior versions.
Refs: [Schema-Registry compatibility](https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html),
[Pact (consumer-driven contracts)](https://docs.pact.io/).
## The pattern (golden test)
1. **Freeze** an artifact produced by an older version (a DB, an export, a recorded API exchange).
2. **Exercise** it with current code.
3. **Assert** it still works (opens / serves / validates).
Wire each golden test into `release.config.json → extraGate` so a release can't
ship if compatibility broke.
## Add the next contract test
- [ ] {{FIRST_GOLDEN_TEST}} — the worked example.
- [ ] Export round-trip: serve a frozen package, assert it renders.
- [ ] API: record consumer expectations; verify the provider still satisfies them.
{
"versionFiles": [
{ "path": "package.json", "kind": "json", "pointer": "/version" }
],
"lockfiles": [],
"gate": "{{GATE_COMMAND}}",
"extraGate": [],
"changelog": { "path": "CHANGELOG.md", "style": "keep-a-changelog", "from": "conventional-commits" },
"compatibility": { "path": "docs/COMPATIBILITY.md" },
"surfaces": [
{ "id": "{{SURFACE_ID}}", "name": "{{SURFACE_NAME}}", "tier": "preview", "mode": "unversioned" }
],
"releaseBranch": "main",
"tag": { "prefix": "v", "push": true }
}