Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 230 additions & 0 deletions .agents/skills/creating-release/SKILL.md

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions .agents/skills/creating-release/references/categorization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Commit categorization

Authoritative mapping from this project's commit prefixes to Keep a Changelog 1.1.0 sections. The skill's classification step (workflow step 4) reads this file.

This project uses six commit types: `feat`, `fix`, `sec`, `revert`, `docs`, `chore`. Four map mechanically; two (`chore` and `docs`) require per-commit judgment.

The release note (`docs/release-notes/<version>.md`) inherits the CHANGELOG.md verdict for its trailing `## Changes` section (it copies that block verbatim). The "In release note body" column below covers only the friendly top half — Summary, Highlights, Breaking, Fixes — where prose is hand-authored.

## Table

| Prefix | Section in CHANGELOG.md | In release note body | Notes |
| ----------- | ---------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `feat` | Added | yes | `feat!` or `BREAKING CHANGE:` footer → also list under `## Important: breaking changes`. |
| `fix` | Fixed | yes | |
| `sec` | Security | yes | |
| `revert` | Removed | yes | Subject typically `revert: <original subject>`. |
| `docs` | judgment per commit (default drop) | judgment | Inspect: changes to user-facing docs (CLI help text, README upgrade guide, public-API reference) → `Changed`. Contributor docs, architecture notes, internal design specs → drop. |
| `chore` | judgment per commit (default drop) | judgment | Inspect every chore. See "Chore triage" below. Never silently drop without checking the body. |
| no prefix | Changed | (judgment) | Add a classification warning at the approval gate so the maintainer can retag. |

Historical commits using prefixes outside the six (`refactor:`, `perf:`, `test:`, `ci:`, `build:`, `style:`, `security:`, `deprecate:`, etc.) are treated as a `chore` of the corresponding kind and triaged per the chore rules below — except `security:`, which maps to `sec` (the modern short form for the same intent).

## Chore triage

`chore` is a catchall. Every chore commit goes through this decision:

1. **Read the body.** Subject alone is not enough.
2. **Does it change user-visible behavior?** Examples that count: a runtime dependency bump on a library shipped with the binary (especially a CVE patch); a refactor that alters logged output, exit codes, or a wire format; a build/CI change that produces a new install path or a renamed artefact; a performance improvement large enough to mention. If yes → include under the matching Keep a Changelog section (`Changed`, `Security`, etc.).
3. **Is it pure repo upkeep?** Examples: test additions, internal refactor with zero observable side effect, formatting/lint fixes, contributor-doc edits, CI tweaks that only affect the maintainer's workflow. If yes → drop.
4. **When in doubt, include.** A bullet the maintainer removes at the approval gate is cheap; a silent drop the maintainer never sees is invisible.

This project does not use commit scopes, so the chore subject alone carries no hint about what kind of upkeep it is. Read the body and the diff — `go.mod`/`go.sum` touched means a dependency change; `.github/workflows/*` means CI; `*_test.go` only means tests; etc. The triage decision lives in the diff, not the subject.

## Breaking changes

A commit is breaking when:

- The subject uses `<type>!:`, e.g. `feat!: drop /v1`.
- The body contains a paragraph starting with `BREAKING CHANGE:`.
- A command is renamed or removed, even with a deprecation window — the old form is breaking for any caller that pinned it. If the author shipped it without `!` or `BREAKING CHANGE:`, surface the inconsistency at the approval gate and retag.

In `CHANGELOG.md`, every breaking commit appears under its underlying Keep a Changelog section (`### Added`, `### Changed`, `### Deprecated`, `### Removed`, `### Fixed`, or `### Security`) with a `**BREAKING:**` prefix on the bullet. Keep a Changelog 1.1.0 has no "Breaking" section, so do not invent one.

```markdown
### Changed

- **BREAKING:** rename `ch attach` to `ch session attach`; old form prints a deprecation warning for one release.
```

In the **release note's friendly top half**, every breaking commit also appears under `## Important: breaking changes` with a user-facing summary. The trailing `## Changes` block is the verbatim CHANGELOG.md copy, so the same bullet shows up there too with its `**BREAKING:**` prefix — that's expected, not a duplication bug.

## Deprecations

When a commit deprecates a public surface (a renamed CLI command, an exported Go API, a config key), the **old form** is listed under `### Deprecated`. The **new form** is listed under its own section (`### Added` or `### Changed`).

Example: a single commit that renames `ch attach` to `ch session attach` produces two classification records, one per surface:

- record A (old form) → section `Deprecated`, `breaking: true`. This is the record summarized under `## Important: breaking changes` in the release note's friendly top half.
- record B (new form) → section `Added` (or `Changed` if the form already existed under a different name), `breaking: false`. This record does *not* appear in any Breaking block.

**Anti-double-listing rule:** within `CHANGELOG.md`, every commit appears at most once per Keep a Changelog section. If you find yourself emitting the same bullet in two sections with the same `**BREAKING:**` marker, the second occurrence is a duplicate — collapse it. The two-record split above is correct because the two records represent two distinct user-visible surfaces (the deprecated old form and the new canonical form).

## Merging commits

When several commits share an obvious theme (same area, same feature), the **friendly top half** of the release note may merge them into one bullet or one Highlight. `CHANGELOG.md` (and therefore the trailing `## Changes` block) keeps one bullet per logical change.

## When in doubt

Pick `Changed` over silent drop. The maintainer reviews the diff at the approval gate.
78 changes: 78 additions & 0 deletions .agents/skills/creating-release/references/keepachangelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Keep a Changelog 1.1.0 — guidelines

Source: https://keepachangelog.com/en/1.1.0/

## Guiding principles (verbatim)

- Changelogs are for humans, not machines.
- There should be an entry for every single version.
- The same types of changes should be grouped.
- Versions and sections should be linkable.
- The latest version comes first.
- The release date of each version is displayed.
- Mention whether you follow Semantic Versioning.

## Types of changes (verbatim)

- **Added** for new features.
- **Changed** for changes in existing functionality.
- **Deprecated** for soon-to-be removed features.
- **Removed** for now removed features.
- **Fixed** for any bug fixes.
- **Security** in case of vulnerabilities.

These six types are the **only** sections allowed inside a version block in `CHANGELOG.md`. Do not invent new sections. Do not split entries across custom blocks. Group by these names exactly.

## Marking breaking changes

Keep a Changelog has no separate "Breaking" section. To call out a breaking change, prefix the affected entry with `**BREAKING:**` and keep it under its correct type. Examples:

```markdown
### Changed

- **BREAKING:** rename `ch attach` to `ch session attach`; the old form prints a deprecation warning for one release.

### Removed

- **BREAKING:** drop the deprecated `--legacy-flag` argument from `ch create worktree`.
```

The per-version release note (`docs/release-notes/<version>.md`) is **not** a Keep a Changelog file in its top half; it carries a top-level `## Important: breaking changes` block to make the impact unmissable. Its trailing `## Changes` section is a verbatim copy of the CHANGELOG.md block and therefore still obeys the six-section rule.

## File shape

```markdown
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

<!-- Add Unreleased entries here. -->

## [<version>] - YYYY-MM-DD

### Added
### Changed
### Deprecated
### Removed
### Fixed
### Security

[Unreleased]: https://github.com/xico42/codeherd/compare/v<version>...HEAD
[<version>]: https://github.com/xico42/codeherd/compare/v<prev>...v<version>
...
[<first-version>]: https://github.com/xico42/codeherd/releases/tag/v<first-version>
```

Omit any section that has no entries. Newest version on top. Footer compare-links keep every version linkable, satisfying the "Versions and sections should be linkable" principle.

## Style notes for entries

- Write for end users. No commit SHAs in this file.
- Present tense, third person ("renames `ch attach`...", not "we renamed").
- One bullet per logical change. Do not batch multiple unrelated changes into one bullet.
- All prose is authored with the `writing-clearly-and-concisely` skill applied.
50 changes: 50 additions & 0 deletions .agents/skills/creating-release/references/semver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Semantic Versioning 2.0.0 — condensed

Source: https://semver.org/spec/v2.0.0.html

## Version format

```
<MAJOR>.<MINOR>.<PATCH>[-<prerelease>][+<build>]
```

- `<MAJOR>`, `<MINOR>`, `<PATCH>` are non-negative integers without leading zeros.
- Prerelease and build metadata are optional. This skill does not produce them.

## When to bump

| Bump | Trigger |
| ----- | ---------------------------------------------------------------------------------------------------- |
| MAJOR | Incompatible / breaking changes to the public API. |
| MINOR | Backwards-compatible feature additions. |
| PATCH | Backwards-compatible bug fixes (including security fixes that don't change behavior). |

When you bump a higher level, lower levels reset to zero. `1.4.7` + breaking change → `2.0.0`, not `2.4.7`.

## The 0.x convention

SemVer §4 says:

> Major version zero (0.y.z) is for initial development. Anything MAY change at any time. The public API SHOULD NOT be considered stable.

Common practice during `0.y.z` — and what this skill applies — is:

| Signal at this level | Bump while major == 0 |
| ------------------------------------- | --------------------- |
| Breaking change (would be MAJOR ≥ 1) | **MINOR** |
| Feature addition (MINOR ≥ 1) | MINOR |
| Bug fix / security | PATCH |

The maintainer can override at invocation time (`release as major` while still at 0.x to declare 1.0.0 explicitly).

## First release

The first tagged release of this project is `0.1.0`. Not `0.0.1` and not `1.0.0`. This is hard-coded in the skill's workflow step 5.

## Precedence rules used by `write-version.sh` (informational)

- Compare `<major>.<minor>.<patch>` numerically left to right.
- A version without prerelease metadata has higher precedence than the same version with prerelease metadata: `1.0.0` > `1.0.0-rc.1`.
- Build metadata is ignored for precedence.

The skill rejects any new version that is not strictly greater than the current `VERSION` by these rules.
152 changes: 152 additions & 0 deletions .agents/skills/creating-release/scripts/rotate-unreleased.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Rotate CHANGELOG.md's [Unreleased] block into a versioned heading.

Usage:
rotate-unreleased.py <new_version> <date> <section_file> [<prev_tag>]

Arguments:
new_version semver, no leading 'v' e.g. 0.2.0
date YYYY-MM-DD e.g. 2026-05-28
section_file path to rendered Keep a Changelog section for the new
version (without the '## [version] - date' heading)
prev_tag previous tag with 'v' prefix; empty for first release

Behavior:
- If CHANGELOG.md is missing, write it from scratch with header,
empty [Unreleased] (placeholder), the new section, and footer
compare-links.
- Otherwise rotate: replace the [Unreleased] body with a placeholder,
insert the new '## [<v>] - <date>' block immediately below it, and
rewrite the footer link references for [Unreleased] and [<v>].

Idempotent guard:
If [<v>] already exists in CHANGELOG.md, exit non-zero with a clear
message — the maintainer is re-running on an already-rotated tree.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

REPO = "xico42/codeherd"
HEADER = """\
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
"""
UNRELEASED_PLACEHOLDER = "<!-- Add Unreleased entries here. -->"


def die(msg: str, code: int = 2) -> None:
print(msg, file=sys.stderr)
sys.exit(code)


def footer_for(version: str, prev_tag: str) -> str:
tag = f"v{version}"
if prev_tag:
version_link = (
f"[{version}]: https://github.com/{REPO}/compare/{prev_tag}...{tag}"
)
else:
version_link = (
f"[{version}]: https://github.com/{REPO}/releases/tag/{tag}"
)
unreleased = f"[Unreleased]: https://github.com/{REPO}/compare/{tag}...HEAD"
return unreleased, version_link


def write_fresh(path: Path, version: str, date: str, section: str, prev_tag: str) -> None:
unreleased_link, version_link = footer_for(version, prev_tag)
section = section.rstrip() + "\n"
body = (
f"{HEADER}\n"
f"## [Unreleased]\n\n"
f"{UNRELEASED_PLACEHOLDER}\n\n"
f"## [{version}] - {date}\n\n"
f"{section}\n"
f"{unreleased_link}\n"
f"{version_link}\n"
)
path.write_text(body, encoding="utf-8")


def rotate(path: Path, version: str, date: str, section: str, prev_tag: str) -> None:
text = path.read_text(encoding="utf-8")

if re.search(rf"^## \[{re.escape(version)}\] - ", text, re.MULTILINE):
die(f"CHANGELOG.md already has [{version}] — refusing to rotate twice.")

unreleased_re = re.compile(r"^## \[Unreleased\]\n", re.MULTILINE)
m = unreleased_re.search(text)
if not m:
die("CHANGELOG.md has no [Unreleased] heading — refusing to guess where to insert.")

# Find end of the Unreleased body: next "## [" heading, or footer link line.
body_start = m.end()
next_heading = re.search(r"^## \[", text[body_start:], re.MULTILINE)
footer_start = re.search(r"^\[Unreleased\]:\s", text[body_start:], re.MULTILINE)

if next_heading is None and footer_start is None:
unreleased_body_end = len(text)
else:
candidates = [c.start() for c in (next_heading, footer_start) if c is not None]
unreleased_body_end = body_start + min(candidates)

head = text[: m.end()]
tail = text[unreleased_body_end:]

# Build the new section block.
section = section.rstrip() + "\n"
new_section_block = f"\n{UNRELEASED_PLACEHOLDER}\n\n## [{version}] - {date}\n\n{section}\n"

# Rewrite footer link references.
unreleased_link, version_link = footer_for(version, prev_tag)
new_unreleased_re = re.compile(r"^\[Unreleased\]:\s.*$", re.MULTILINE)
if new_unreleased_re.search(tail):
tail = new_unreleased_re.sub(unreleased_link, tail, count=1)
# Prepend the new version link immediately after the updated Unreleased link line.
tail = re.sub(
re.escape(unreleased_link) + r"\n",
unreleased_link + "\n" + version_link + "\n",
tail,
count=1,
)
else:
# No footer in tail — append one.
tail = tail.rstrip() + "\n\n" + unreleased_link + "\n" + version_link + "\n"

path.write_text(head + new_section_block + tail, encoding="utf-8")


def main(argv: list[str]) -> None:
if len(argv) < 4 or len(argv) > 5:
die(__doc__ or "bad args")

version, date, section_path = argv[1], argv[2], argv[3]
prev_tag = argv[4] if len(argv) == 5 else ""

if not re.match(r"^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$", version):
die(f"not a valid semver: {version}")
if not re.match(r"^\d{4}-\d{2}-\d{2}$", date):
die(f"date must be YYYY-MM-DD: {date}")
if prev_tag and not re.match(r"^v\d+\.\d+\.\d+", prev_tag):
die(f"prev_tag must look like vX.Y.Z: {prev_tag}")

section = Path(section_path).read_text(encoding="utf-8")
changelog = Path("CHANGELOG.md")

if not changelog.exists():
write_fresh(changelog, version, date, section, prev_tag)
return

rotate(changelog, version, date, section, prev_tag)


if __name__ == "__main__":
main(sys.argv)
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{{!-- Body injected into CHANGELOG.md under the version heading.
Render by direct text substitution. No template engine.
- {{Version}}, {{Date}} are required.
- Iterate {{Sections}} in fixed Keep a Changelog order: Added, Changed, Deprecated, Removed, Fixed, Security.
- Omit any section that has no entries (do not print empty headings).
- For each entry in a section, if {{.Breaking}} is true, prefix the bullet with `**BREAKING:**`.
- Entries are human-readable prose, no commit SHAs (per Keep a Changelog).
- Apply writing-clearly-and-concisely to every .Summary.
--}}
## [{{Version}}] - {{Date}}

### Added

- {{#if .Breaking}}**BREAKING:** {{/if}}{{.Summary}}

### Changed

- {{#if .Breaking}}**BREAKING:** {{/if}}{{.Summary}}

### Deprecated

- {{#if .Breaking}}**BREAKING:** {{/if}}{{.Summary}}

### Removed

- {{#if .Breaking}}**BREAKING:** {{/if}}{{.Summary}}

### Fixed

- {{.Summary}}

### Security

- {{.Summary}}
Loading
Loading