references/calver.md
# CalVer Setup
Use this when the project has no existing version convention, or has explicitly opted into date-based versions like `YYYY.M.COMMITS` (e.g. `2026.4.142`).
## Versioning behaviour
- `python3 scripts/version.py version` prints today's CalVer to stdout
- `python3 scripts/version.py stamp` (no `--version`) auto-computes CalVer and stamps both CHANGELOG.md and any discovered config files (`package.json`, `Cargo.toml`, `pyproject.toml`, `tauri.conf.json`, `VERSION`)
- Manual override: `--version X.Y.Z`
- The version increases monotonically as long as commits accumulate; CI must use `fetch-depth: 0`
## Build system integration
### Makefile
```makefile
# Auto-compute CalVer and stamp CHANGELOG + discovered config files.
.PHONY: stamp-version
stamp-version:
uv run scripts/version.py stamp
# Auto CalVer (no args) or manual override (V=X.Y.Z).
.PHONY: version
version:
@if [ -n "$(V)" ]; then \
uv run scripts/version.py stamp --version "$(V)"; \
else \
uv run scripts/version.py stamp; \
fi
```
If `build` or `release` targets exist, add `stamp-version` as a dependency.
### Justfile
```just
stamp-version:
uv run scripts/version.py stamp
version ver="":
#!/usr/bin/env bash
if [ -n "{{ver}}" ]; then
uv run scripts/version.py stamp --version "{{ver}}"
else
uv run scripts/version.py stamp
fi
```
### package.json
```json
{
"scripts": {
"version": "uv run scripts/version.py stamp",
"stamp-version": "uv run scripts/version.py stamp"
}
}
```
## CLAUDE.md snippet
```markdown
Update CHANGELOG.md under the [Unreleased] section with concise bullet points grouped under Added/Changed/Fixed/Removed. Combine or update items refined within the same session. Don't add version numbers; the build process computes the CalVer at release time via `make stamp-version`. Truncate when the file exceeds 2000 lines.
```
Replace `make stamp-version` with `just stamp-version` or `npm run stamp-version` to match the project's build system.
## GitHub Actions
```yaml
on:
workflow_dispatch:
inputs:
version:
description: "Version override (leave empty for auto CalVer)"
required: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required for CalVer commit count
persist-credentials: false
- name: Stamp version
id: version
run: |
if [[ -n "${{ inputs.version }}" ]]; then
V="${{ inputs.version }}"
else
V=$(uv run scripts/version.py version)
fi
echo "version=$V" >> "$GITHUB_OUTPUT"
uv run scripts/version.py stamp --version "$V"
```
## Gotchas
- `fetch-depth: 0` is mandatory; shallow clones produce wrong commit counts and a CalVer that goes backwards
- Apple's App Store accepts CalVer (any monotonically increasing three-integer string is valid for `CFBundleShortVersionString`), but reviewers occasionally query unfamiliar version formats
- If a project converts from CalVer to SemVer mid-flight, the changelog history mixes formats; that's fine, but the most recent entries should match the current scheme
references/changelog-template.md
# Changelog Template
Generate this as the project's `CHANGELOG.md`. Adapt the comment text if the project has specific conventions.
```markdown
# Changelog
<!-- AI agents: add entries under the ## [Unreleased] header. Do NOT add version numbers or dates. Do NOT duplicate headings. The ## Known Bugs section must always stay pinned above ## [Unreleased]. Group entries under ### Added, ### Changed, ### Fixed, or ### Removed. Combine or update items refined within the same session. If the file exceeds 2000 lines, truncate the oldest releases. -->
## Known Bugs
## [Unreleased]
```
## Entry format
Each entry is a concise bullet point under a category heading:
```markdown
## [Unreleased]
### Added
- New feature description
### Changed
- What changed and why
### Fixed
- What was broken and how it was fixed
### Removed
- What was removed and why
```
For security or critical fixes, use bold severity prefixes for scanability:
```markdown
### Fixed
- **Security**: Shell injection in env command via unescaped quotes
- **Critical**: TUI dead-end state when pressing 'o'
- Regular bug fix description
```
## Adapting for existing projects
If the project already has a CHANGELOG.md:
1. Do not overwrite existing history
2. Insert the HTML comment block after the `# Changelog` heading
3. Add `## Known Bugs` and `## [Unreleased]` sections above the first versioned entry
4. Preserve all existing versioned entries below
references/dated.md
# Dated Setup
Use this for non-code projects: docs repos, writing or notes vaults, content sites, research collections, config-only repos. Also use it for any project that just wants a plain change log with no version numbers (a skill directory, a dotfiles repo). No build system, no `version.py`, no CI. The agent maintains the changelog by hand under date headings.
## How it works
There is no stamping script and no build integration. Whenever an agent makes a change, it adds a terse TLDR bullet under today's date heading (`## YYYY-MM-DD`), newest date first. The agent creates today's heading if it doesn't exist yet. That's the whole workflow.
## CHANGELOG.md
Generate this as the project's `CHANGELOG.md` (use today's real date for the first heading and describe the actual change you just made):
```markdown
# Changelog
<!-- AI agents: After completing changes to this project, add a terse TLDR style bullet describing the change under today's date heading (## YYYY-MM-DD), newest date first. Create the date heading if it does not exist. No versioning is required. -->
## 2026-06-30
- Added CHANGELOG.md and CLAUDE.md to track future changes.
```
For an existing CHANGELOG.md, do not overwrite history: insert the HTML comment after the `# Changelog` heading and add today's date heading above the most recent existing entry.
## Entry format
One bullet per change, terse, under the date heading. Group with `###` sub-headings only if a single day's entries get long enough to need it:
```markdown
## 2026-06-30
- Rewrote the onboarding guide intro
- Fixed broken links in the API reference
- Removed the deprecated migration page
```
## CLAUDE.md
If the project has no CLAUDE.md, create one (replace `<Project Name>` with the real name):
```markdown
# <Project Name> Rules
## Update CHANGELOG.md after changes
After making any change to this project: You MUST update `CHANGELOG.md`:
- Add a concise TLDR of the change(s) as bullet point(s) under today's date heading (`## YYYY-MM-DD`, newest first), creating the heading if it doesn't exist. No versioning is required.
```
If a CLAUDE.md already exists, append just the `## Update CHANGELOG.md after changes` section to it; don't add a second top-level title. Tailor the "any change to this project" phrasing to the project's content if it helps (e.g. "any change to this skill (SKILL.md, references, scripts, evals)").
## Gotchas
- No script means nothing enforces this; the CLAUDE.md instruction is the only mechanism. Keep it terse and imperative so agents actually follow it.
- Agents must use the real current date for the heading, not a guessed or placeholder one.
- If the project later grows a build system and version convention, switch to `calver.md` or `semver.md`; the existing dated entries stay as-is.
references/semver.md
# SemVer Setup
Use this when the project already declares SemVer (CHANGELOG mentions "SemVer", git tags are `vX.Y.Z`, or a config file holds an explicit `X.Y.Z` version). SemVer projects have a source of truth for the current version; the script does not auto-compute.
## Source of truth
Pick one canonical source. The script can stamp into any of these when it discovers them at the repo root:
- Plain `VERSION` file (one line, `X.Y.Z`)
- `package.json` `version` field
- `Cargo.toml` `version` field
- `pyproject.toml` `version` field
Multi-source projects (e.g. a Cargo workspace with a `VERSION` file too) get all matching files stamped on each call.
## Versioning behaviour
- The script never auto-computes a SemVer. `--version X.Y.Z` is required at release time
- Format must match `^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$`
- Validation belongs in the build system, not the script (so the same script serves both schemes)
## Build system integration
### Makefile (canonical source = VERSION file)
```makefile
# Freeze CHANGELOG [Unreleased] using the version currently in VERSION.
# No-op if [Unreleased] is empty.
.PHONY: stamp-version
stamp-version:
@V=$$(cat VERSION | tr -d '[:space:]'); \
if command -v uv >/dev/null 2>&1; then \
uv run scripts/version.py stamp --version "$$V" --changelog-only; \
else \
python3 scripts/version.py stamp --version "$$V" --changelog-only; \
fi
# Bump version: writes VERSION (and any discovered manifest), freezes CHANGELOG.
# Usage: make version V=0.2.0
.PHONY: version
version:
@if [ -z "$(V)" ]; then \
echo "ERROR: pass V=X.Y.Z, e.g. make version V=0.2.0"; exit 1; \
fi
@if ! echo "$(V)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$$'; then \
echo "ERROR: '$(V)' is not a valid semver string"; exit 1; \
fi
@if command -v uv >/dev/null 2>&1; then \
uv run scripts/version.py stamp --version "$(V)"; \
else \
python3 scripts/version.py stamp --version "$(V)"; \
fi
```
`make version V=0.2.0` rewrites `VERSION` in place via the script's `VERSION` handler and freezes `[Unreleased]` as `[0.2.0]`.
### Makefile (canonical source = package.json / Cargo.toml / pyproject.toml)
If the project's package manifest already holds the canonical version, `stamp-version` should read that value rather than a separate `VERSION` file:
```makefile
# Reads version from package.json (or Cargo.toml / pyproject.toml).
.PHONY: stamp-version
stamp-version:
@V=$$(node -p "require('./package.json').version"); \
uv run scripts/version.py stamp --version "$$V" --changelog-only
# Bump: pass V=X.Y.Z, the script rewrites the manifest in place.
.PHONY: version
version:
@if [ -z "$(V)" ]; then echo "ERROR: pass V=X.Y.Z"; exit 1; fi
uv run scripts/version.py stamp --version "$(V)"
```
For Cargo: `V=$$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[0].version')`. For pyproject: `V=$$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")`.
### Justfile
```just
stamp-version:
#!/usr/bin/env bash
V=$(cat VERSION | tr -d '[:space:]')
uv run scripts/version.py stamp --version "$V" --changelog-only
version ver:
#!/usr/bin/env bash
if ! echo "{{ver}}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then
echo "ERROR: '{{ver}}' is not a valid semver string"; exit 1
fi
uv run scripts/version.py stamp --version "{{ver}}"
```
### package.json scripts
```json
{
"scripts": {
"stamp-version": "node -e \"const v=require('./package.json').version; require('child_process').execSync('uv run scripts/version.py stamp --version '+v+' --changelog-only',{stdio:'inherit'})\"",
"version:bump": "uv run scripts/version.py stamp --version"
}
}
```
Use `npm run version:bump 0.2.0`.
## CLAUDE.md snippet
```markdown
Update CHANGELOG.md under the [Unreleased] section with concise bullet points grouped under Added/Changed/Fixed/Removed. Combine or update items refined within the same session. Don't add version numbers; at release time use `make version V=X.Y.Z` to bump the canonical version source and freeze the changelog (or `make stamp-version` to freeze using the existing version). Truncate when the file exceeds 2000 lines.
```
Replace `make` with `just`/`npm run` to match the project's build system.
## GitHub Actions
```yaml
on:
workflow_dispatch:
inputs:
version:
description: "Semver version (leave empty to use VERSION file)"
required: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Stamp version
id: version
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
if [[ -n "$INPUT_VERSION" ]]; then
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "ERROR: '$INPUT_VERSION' is not valid semver"; exit 1
fi
echo "$INPUT_VERSION" > VERSION
fi
V=$(tr -d '[:space:]' < VERSION)
echo "version=$V" >> "$GITHUB_OUTPUT"
python3 scripts/version.py stamp --version "$V" --changelog-only
```
## Gotchas
- **Never call the script without `--version` for a SemVer project.** With no `--version`, the script falls back to auto-computing CalVer and would silently stamp a date-based version into a SemVer changelog. The Makefile/Justfile recipes here always pass `--version`; preserve that
- The semver regex appears in three places (Makefile, Justfile, GH Actions). If you tighten it (e.g. to require pre-release format), tighten all three. Don't move validation into the script; the script is scheme-agnostic by design
- `--changelog-only` skips config file stamping. Use it in `stamp-version` (which only freezes the changelog) but NOT in `version` (which is bumping the canonical source and should stamp it too)
- The script's `VERSION` file handler only stamps if the existing content matches `^\d+\.\d+\.\d+`. If the file holds something else (e.g. a build number, a tag prefix), the script leaves it alone and the Makefile's `make version` won't update it. Convert the file format first or stamp it manually
scripts/version.py
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.12"
# ///
import argparse
import json
import re
import subprocess
import sys
from datetime import date
from pathlib import Path
def find_project_root(start: Path) -> Path:
current = start.resolve()
while current != current.parent:
if (current / ".git").exists():
return current
current = current.parent
print("Error: could not find .git directory in any parent", file=sys.stderr)
sys.exit(1)
def git_commit_count(root: Path) -> int:
try:
result = subprocess.run(
["git", "rev-list", "--count", "HEAD"],
capture_output=True,
text=True,
check=True,
cwd=root,
)
return int(result.stdout.strip())
except subprocess.CalledProcessError as e:
print(f"Error: git rev-list failed: {e.stderr.strip()}", file=sys.stderr)
sys.exit(1)
def compute_calver(root: Path) -> str:
today = date.today()
commits = git_commit_count(root)
return f"{today.year}.{today.month}.{commits}"
def discover_config_files(root: Path) -> list[Path]:
candidates = [
root / "package.json",
root / "Cargo.toml",
root / "pyproject.toml",
root / "tauri.conf.json",
root / "src-tauri" / "tauri.conf.json",
root / "VERSION",
]
return [p for p in candidates if p.is_file()]
def _detect_json_indent(text: str) -> int:
for line in text.splitlines()[1:]:
stripped = line.lstrip()
if stripped:
return len(line) - len(stripped)
return 2
def stamp_json_file(path: Path, version: str, dry_run: bool) -> str | None:
with open(path, encoding="utf-8") as f:
raw = f.read()
data = json.loads(raw)
if "version" not in data:
return None
old_version = data["version"]
if old_version == version:
return None
if not dry_run:
indent = _detect_json_indent(raw)
data["version"] = version
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent, ensure_ascii=False)
f.write("\n")
return old_version
def stamp_toml_file(path: Path, version: str, dry_run: bool) -> str | None:
with open(path, encoding="utf-8") as f:
content = f.read()
pattern = re.compile(r'^(version\s*=\s*)"([^"]*)"', re.MULTILINE)
match = pattern.search(content)
if not match:
return None
old_version = match.group(2)
if old_version == version:
return None
if not dry_run:
new_content = pattern.sub(rf'\g<1>"{version}"', content, count=1)
with open(path, "w", encoding="utf-8") as f:
f.write(new_content)
return old_version
def stamp_version_file(path: Path, version: str, dry_run: bool) -> str | None:
with open(path, encoding="utf-8") as f:
content = f.read().strip()
# Only stamp if existing content looks like a version. This avoids
# rewriting files named VERSION that hold something else (build numbers,
# tag-prefixed strings, multi-line metadata).
if not re.match(r"^\d+\.\d+\.\d+", content):
return None
if content == version:
return None
if not dry_run:
with open(path, "w", encoding="utf-8") as f:
f.write(version + "\n")
return content
def stamp_config_file(path: Path, version: str, dry_run: bool) -> str | None:
if path.suffix == ".json":
return stamp_json_file(path, version, dry_run)
elif path.suffix == ".toml":
return stamp_toml_file(path, version, dry_run)
elif path.name == "VERSION":
return stamp_version_file(path, version, dry_run)
return None
def stamp_changelog(root: Path, version: str, dry_run: bool) -> bool:
changelog = root / "CHANGELOG.md"
if not changelog.is_file():
print("Warning: CHANGELOG.md not found, skipping", file=sys.stderr)
return False
with open(changelog, encoding="utf-8") as f:
lines = f.readlines()
unreleased_idx = None
for i, line in enumerate(lines):
if re.match(r"^## \[Unreleased\]", line):
unreleased_idx = i
break
if unreleased_idx is None:
print(
"Warning: no '## [Unreleased]' heading found in CHANGELOG.md, skipping",
file=sys.stderr,
)
return False
next_heading_idx = None
for i in range(unreleased_idx + 1, len(lines)):
if re.match(r"^## ", lines[i]):
next_heading_idx = i
break
content_end = next_heading_idx if next_heading_idx is not None else len(lines)
section_lines = lines[unreleased_idx + 1 : content_end]
has_content = any(line.strip() for line in section_lines)
if not has_content:
print(
"Warning: Unreleased section is empty, skipping changelog stamp",
file=sys.stderr,
)
return False
today_str = date.today().isoformat()
version_heading = f"## [{version}] - {today_str}\n"
lines[unreleased_idx] = version_heading
fresh_unreleased = "## [Unreleased]\n\n"
known_bugs_idx = None
for i, line in enumerate(lines):
if re.match(r"^## Known Bugs", line):
known_bugs_idx = i
break
ai_comment_idx = None
changelog_heading_idx = None
for i, line in enumerate(lines):
if ai_comment_idx is None and re.match(r"^<!-- AI agents:", line):
ai_comment_idx = i
if changelog_heading_idx is None and re.match(r"^# Changelog", line):
changelog_heading_idx = i
stamped_heading_idx = None
for i, line in enumerate(lines):
if line == version_heading:
stamped_heading_idx = i
break
if known_bugs_idx is not None and stamped_heading_idx is not None and known_bugs_idx < stamped_heading_idx:
kb_content_end = stamped_heading_idx
while kb_content_end > known_bugs_idx + 1 and not lines[kb_content_end - 1].strip():
kb_content_end -= 1
lines = lines[:kb_content_end] + ["\n"] + [fresh_unreleased] + lines[stamped_heading_idx:]
elif ai_comment_idx is not None:
insert_at = ai_comment_idx + 1
lines.insert(insert_at, "\n")
lines.insert(insert_at + 1, fresh_unreleased)
elif changelog_heading_idx is not None:
insert_at = changelog_heading_idx + 1
lines.insert(insert_at, "\n")
lines.insert(insert_at + 1, fresh_unreleased)
else:
lines.insert(0, fresh_unreleased)
if not dry_run:
with open(changelog, "w", encoding="utf-8") as f:
f.writelines(lines)
return True
def cmd_version(_args: argparse.Namespace) -> None:
root = find_project_root(Path(__file__).parent)
version = compute_calver(root)
print(version)
def cmd_stamp(args: argparse.Namespace) -> None:
root = find_project_root(Path(__file__).parent)
version = args.version if args.version else compute_calver(root)
dry_run = args.dry_run
prefix = "Dry run for" if dry_run else "Stamping"
print(f"{prefix} version {version}...", file=sys.stderr)
changes_made = False
if not args.no_changelog:
changed = stamp_changelog(root, version, dry_run)
if changed:
today_str = date.today().isoformat()
suffix = " (would change)" if dry_run else ""
print(
f" CHANGELOG.md: [Unreleased] -> [{version}] - {today_str}{suffix}",
file=sys.stderr,
)
changes_made = True
if not args.changelog_only:
for config_path in discover_config_files(root):
old_version = stamp_config_file(config_path, version, dry_run)
if old_version is not None:
rel = config_path.relative_to(root)
suffix = " (would change)" if dry_run else ""
print(f" {rel}: {old_version} -> {version}{suffix}", file=sys.stderr)
changes_made = True
if dry_run:
print("No files modified (dry run).", file=sys.stderr)
elif changes_made:
print("Done.", file=sys.stderr)
else:
print("No files needed updating.", file=sys.stderr)
def main() -> None:
parser = argparse.ArgumentParser(description="CalVer versioning and changelog stamping")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("version", help="Compute and print the CalVer version")
stamp_parser = subparsers.add_parser("stamp", help="Stamp version into changelog and config files")
stamp_parser.add_argument("--version", help="Manual version override")
stamp_parser.add_argument("--dry-run", action="store_true", help="Preview changes without modifying files")
stamp_parser.add_argument("--changelog-only", action="store_true", help="Only stamp CHANGELOG.md")
stamp_parser.add_argument("--no-changelog", action="store_true", help="Skip CHANGELOG.md stamping")
args = parser.parse_args()
if args.command == "version":
cmd_version(args)
elif args.command == "stamp":
cmd_stamp(args)
if __name__ == "__main__":
main()
SKILL.md
---
name: ai-changelog
description: Set up an AI-driven changelog system in any project, code or not. Suitable for both software projects and for non-code projects. Creates a CHANGELOG.md and instructions for agents to log changes. Use when the user wants to add or improve automated changelog management, AI-friendly changelog workflows, version stamping, or set up a changelog system for a new or existing project.
allowed-tools: Read Write Edit Bash Glob Grep
---
# AI-Driven Changelog
Set up a changelog system AI agents maintain during development. Two shapes, chosen by project type:
- **Software projects**: agents write entries under `## [Unreleased]`; automation stamps version numbers at release time (CalVer or SemVer). No agent ever writes version numbers; the build process handles that.
- **Non-code projects** (no build system): agents add terse bullets under a date heading (`## YYYY-MM-DD`) by hand. No script, no version numbers. See `references/dated.md`.
## Setup workflow
1. **Detect the build system**: Check for Makefile, Justfile, package.json, Cargo.toml, pyproject.toml, go.mod. Note which config files contain a `"version"` field.
2. **Check whether this is a software project at all.** If there's no build system from step 1 and no version convention (a docs repo, writing or notes vault, content site, research or config collection), use **Dated** mode: date-based changelog headings, no version script, no build integration. Skip straight to `references/dated.md` and ignore the script/build steps below. Otherwise, **detect the versioning scheme** by inspecting (highest confidence first):
- `CHANGELOG.md` heading style: `## [YYYY.M.N]` headings → CalVer; `## [X.Y.Z]` headings or prose mentioning "SemVer" → SemVer
- Git tags from `git tag --list | head`: `vX.Y.Z` → SemVer; `YYYY.M.N` → CalVer
- `VERSION` file with content matching `^[0-9]+\.[0-9]+\.[0-9]+` → SemVer
- Manifest version field (`package.json`, `Cargo.toml`, `pyproject.toml`) matching `X.Y.Z` → SemVer
If signals are absent or contradictory, ask the user. Suggest SemVer for projects with established version history (existing tags, manifest versions, prior changelog entries) and CalVer for greenfield projects where automatic versioning is preferable. Each scheme has trade-offs documented in its reference file.
3. **Read the scheme reference** that matches the chosen scheme. It contains the build integration recipes, CLAUDE.md snippet, GitHub Actions pattern, and scheme-specific gotchas:
- CalVer → `references/calver.md`
- SemVer → `references/semver.md`
- Dated (non-code projects) → `references/dated.md`
4. **Ask the user** about optional features (CalVer/SemVer only; skip for Dated mode):
- Pinned `## Known Bugs` section above `## [Unreleased]`? (default: yes)
- GitHub Actions release workflow integration? (default: skip unless asked)
5. **Generate or update CHANGELOG.md**. For CalVer/SemVer use `references/changelog-template.md`; for Dated use the template in `references/dated.md`. If a CHANGELOG.md already exists, do NOT overwrite it; insert the HTML comment and the appropriate structure (`## [Unreleased]` for CalVer/SemVer, today's date heading for Dated) above existing entries.
6. **Copy `scripts/version.py`** from this skill into the target project's `scripts/` directory. Make it executable (`chmod +x`). _Skip in Dated mode - there is no script._
7. **Apply the scheme reference**: follow the build-integration recipe from the chosen reference file. Add targets to the existing build system, or create a minimal Makefile if none exists. _Skip in Dated mode - there is no build integration._
8. **Update CLAUDE.md** with the snippet from the chosen scheme reference. Insert into the project's development workflow section, or create one. In Dated mode this is the only mechanism that keeps the changelog current, so make sure the snippet lands.
9. **Verify**:
- CalVer: `uv run scripts/version.py version` should print today's CalVer; then `uv run scripts/version.py stamp --dry-run` previews the stamp
- SemVer: `uv run scripts/version.py stamp --version <current-version> --dry-run --changelog-only` previews the stamp without touching the canonical version source
- Dated: confirm CHANGELOG.md has today's date heading and the CLAUDE.md snippet is in place; nothing to run
## Project detection
| Indicator | Config files to stamp | Build integration |
|---|---|---|
| `Makefile` | depends on project | Add make targets |
| `Justfile` | depends on project | Add just recipes |
| `package.json` | `package.json` | Add npm scripts or Makefile |
| `Cargo.toml` | `Cargo.toml` | Makefile wrapper |
| `pyproject.toml` | `pyproject.toml` | Makefile wrapper |
| `go.mod` | `VERSION` (if present) or none | Makefile wrapper |
| `VERSION` file | `VERSION` | Makefile wrapper |
| None (code project) | none | Create minimal Makefile |
| None (non-code project) | none | Dated mode - no script, no Makefile |
## How the script works
`scripts/version.py` subcommands:
- `version`: prints today's CalVer to stdout (CalVer projects only)
- `stamp`: replaces `## [Unreleased]` with `## [VERSION] - DATE`, re-inserts a fresh `## [Unreleased]`, and stamps version into auto-discovered config files (`package.json`, `Cargo.toml`, `pyproject.toml`, `tauri.conf.json`, `VERSION`)
Flags: `--version X.Y.Z` (required for SemVer; optional override for CalVer), `--dry-run`, `--changelog-only`, `--no-changelog`.
The script is scheme-agnostic. With no `--version`, it auto-computes CalVer; with `--version`, it stamps whatever string you give it. Validation lives in the build system, so SemVer recipes always pass `--version` and reject malformed input before invoking the script. See `references/semver.md` for why this split matters.
Run via `uv run scripts/version.py stamp` or `python3 scripts/version.py stamp` (no external dependencies).
## Gotchas
- **Never overwrite existing changelog history.** If a CHANGELOG.md exists with content, merge the `[Unreleased]` structure into it rather than replacing the file.
- **Empty Unreleased section**: stamping is a no-op if `[Unreleased]` has no content. This prevents empty version entries.
- **`fetch-depth: 0` in CI for CalVer**: CalVer uses `git rev-list --count HEAD`. Shallow clones produce wrong commit counts.
- **The HTML comment is the agent's instruction source.** The `<!-- AI agents: ... -->` comment in CHANGELOG.md tells future agents how to write entries. Don't omit it.
- **Known Bugs stays pinned.** The stamp script preserves `## Known Bugs` above `## [Unreleased]`. If you add it, agents should maintain it there.
- **SemVer + auto-CalVer = silent footgun.** If a Makefile in a SemVer project calls `python3 scripts/version.py stamp` without `--version`, the script auto-computes CalVer and writes that into the changelog. The recipes in `references/semver.md` always pass `--version`; preserve that contract in any custom integration.
- **Config file stamping is first-match-only for TOML.** The regex replaces only the first `version = "..."` line, which is the package version. Dependency versions are unaffected.
- **VERSION file stamping requires existing semver-shaped content.** The script's `VERSION` handler only rewrites the file if its current content matches `^\d+\.\d+\.\d+`. Build numbers, tag-prefixed versions, or other formats are left alone.