
Ai Changelog
- 34 installs
- 154 repo stars
- Updated July 30, 2026
- sammcj/agentic-coding
Helps with ai & agent building tasks.
About
ai-changelog is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- ai-changelog
- AI & Agent Building
- AI-coding skill
Ai Changelog by the numbers
- 34 all-time installs (skills.sh)
- +1 installs in the week ending Jul 26, 2026 (Skillselion tracking)
- Ranked #8,777 of 16,556 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
npx skills add https://github.com/sammcj/agentic-coding --skill ai-changelogAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 34 |
|---|---|
| repo stars | ★ 154 |
| Last updated | July 30, 2026 |
| Repository | sammcj/agentic-coding ↗ |
What it does
Helps with ai & agent building tasks.
Files
AI-Driven Changelog
Set up a changelog system where AI coding agents write entries under ## [Unreleased] during development, and automation stamps version numbers at release time. No agent ever writes version numbers; the build process handles that.
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. Detect the versioning scheme by inspecting (highest confidence first):
CHANGELOG.mdheading 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 VERSIONfile with content matching^[0-9]+\.[0-9]+\.[0-9]+→ SemVer- Manifest version field (
package.json,Cargo.toml,pyproject.toml) matchingX.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
4. Ask the user about optional features:
- Pinned
## Known Bugssection above## [Unreleased]? (default: yes) - GitHub Actions release workflow integration? (default: skip unless asked)
5. Generate or update CHANGELOG.md using references/changelog-template.md. If a CHANGELOG.md already exists, do NOT overwrite it; insert the HTML comment and the ## [Unreleased] (and optional ## Known Bugs) structure above existing entries.
6. Copy `scripts/version.py` from this skill into the target project's scripts/ directory. Make it executable (chmod +x).
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.
8. Update CLAUDE.md with the snippet from the chosen scheme reference. Insert into the project's development workflow section, or create one.
9. Verify:
- CalVer:
uv run scripts/version.py versionshould print today's CalVer; thenuv run scripts/version.py stamp --dry-runpreviews the stamp - SemVer:
uv run scripts/version.py stamp --version <current-version> --dry-run --changelog-onlypreviews the stamp without touching the canonical version source
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 | none | Create minimal 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 Bugsabove## [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 stampwithout--version, the script auto-computes CalVer and writes that into the changelog. The recipes inreferences/semver.mdalways 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
VERSIONhandler only rewrites the file if its current content matches^\d+\.\d+\.\d+. Build numbers, tag-prefixed versions, or other formats are left alone.
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 versionprints today's CalVer to stdoutpython3 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
# 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; \
fiIf build or release targets exist, add stamp-version as a dependency.
Justfile
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
fipackage.json
{
"scripts": {
"version": "uv run scripts/version.py stamp",
"stamp-version": "uv run scripts/version.py stamp"
}
}CLAUDE.md snippet
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
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: 0is 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
Changelog Template
Generate this as the project's CHANGELOG.md. Adapt the comment text if the project has specific conventions.
# 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:
## [Unreleased]
### Added
- New feature description
### Changed
- What changed and why
### Fixed
- What was broken and how it was fixed
### Removed
- What was removed and whyFor security or critical fixes, use bold severity prefixes for scanability:
### Fixed
- **Security**: Shell injection in env command via unescaped quotes
- **Critical**: TUI dead-end state when pressing 'o'
- Regular bug fix descriptionAdapting 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
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
VERSIONfile (one line,X.Y.Z) package.jsonversionfieldCargo.tomlversionfieldpyproject.tomlversionfield
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.Zis 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)
# 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)"; \
fimake 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:
# 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
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
{
"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
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
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-onlyGotchas
- 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-onlyskips config file stamping. Use it instamp-version(which only freezes the changelog) but NOT inversion(which is bumping the canonical source and should stamp it too)- The script's
VERSIONfile 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'smake versionwon't update it. Convert the file format first or stamp it manually
#!/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()