By Contexory

Changeset Writer

Writes the release note for a change using the repository's own convention, and picks the version bump from what the change did to the public surface rather than from how big the diff looks. Detects Changesets, Conventional Commits or a hand-maintained changelog and follows whichever is already in use. Use when the user needs a changeset, release note, changelog entry or version bump decision for work they just finished.

1 supporting file

Changeset writer

Two things go wrong with release notes, and both are mechanical enough to fix.

The note describes the diff instead of the consequence — "refactored the parser" tells a consumer nothing about whether to upgrade. And the bump is chosen by feel: a large diff gets a minor, a small one gets a patch, when the only question that matters is what happened to the surface other people depend on.

Procedure

1. Read the change's effect on the public surface

<skill-dir> is the directory this SKILL.md was loaded from — the skill installs outside your project, so its script is named by full path, never relatively.

python3 <skill-dir>/scripts/change_surface.py             # working tree vs merge-base with main
python3 <skill-dir>/scripts/change_surface.py origin/main # explicit base

The script reports which packages the diff touches, whether each is published or private, the exported symbols added, removed and signature-changed per package, and the repository's existing release convention with an example of a recent entry.

2. Pick the bump from the surface, not the diff size

  • major — an export was removed, renamed, or had its signature narrowed; a default

    changed in a way existing callers would notice; a runtime floor was raised

  • minor — an export was added, and nothing existing changed
  • patch — no change to the public surface at all

A private package usually needs no changeset. Say so rather than writing an empty one.

A large internal refactor with an unchanged surface is a patch. This is the case people get wrong most often, and getting it right is most of this skill's value.

3. Write for the person deciding whether to upgrade

They want to know: what can I now do that I could not, what will break, and what must I change. Nothing else belongs in the entry. In particular, leave out the internal path — no file names, no "moved X into Y", no ticket references unless the convention already includes them.

If the change is user-invisible, the honest entry is a one-liner. Padding it is worse than brevity.

4. Match the existing voice exactly

The script prints recent entries. Match their person, tense, and whether they begin with a verb. A correct entry in the wrong voice still reads as an outsider's, and consistency here is most of what a changelog is for.

5. Flag a breaking change loudly, and write the migration inline

If step 2 landed on major, the entry needs the before and the after in code, not a description of them. A consumer reading a breaking change wants to see the two lines side by side.

Output

The entry itself, in the repository's format and ready to paste or write to the right path — plus, separately:

  1. Bump and why — which surface change drove it
  2. Packages affected — and which were skipped as private
  3. Anything you could not classify — a behaviour change that the surface diff cannot

    see is worth naming explicitly

What this skill deliberately does not do

  • It does not invent a convention. If the repository has no changeset or changelog

    system, it says so and asks rather than introducing one — adding .changeset/ to a repo that does not use Changesets is a real and annoying mess to undo.

  • It does not bump versions or edit package.json. Version numbers are the release

    tool's job.

  • It does not describe the implementation. File names and refactor narration are the

    most common failure in generated release notes.

  • It does not write an entry for a private package without saying it is probably

    unnecessary.

  • It does not upgrade a patch to a minor to make the change sound more significant.
  • It does not tag, push, or publish.

When this is the wrong tool

  • You want the change checked, not described. This assumes the change is settled and

    only its announcement is open. Review it first if that is not true.

  • You want to know what the change might break. That is a blast-radius question and it

    is answered by call sites, not by release notes.

  • The change is a dependency bump. Its consequences come from that dependency's own

    changelog read against your call sites, not from your diff.

Supporting files

1 file

scripts/change_surface.pyPython10 KB
#!/usr/bin/env python3
"""Report what a diff did to the public surface, and how this repo writes releases.

The bump belongs to the surface change, not to the diff size, so this reports the
surface change: exports added, removed and signature-altered, per package, with
each package marked published or private. It also detects the release convention
already in use and prints recent entries to match.

    python3 change_surface.py [base-ref]

It classifies nothing on its own — a removed export is reported as removed, and
whether that is major is a judgement the caller makes with the deprecation
history in view. Standard library only; nothing is written.
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
from collections import defaultdict

SOURCE_RE = re.compile(r"\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs)$")
TEST_RE = re.compile(r"(^|/)(tests?|__tests__|spec)/|\.(test|spec)\.[jt]sx?$|_test\.(py|go)$")
SKIP_RE = re.compile(r"(^|/)(node_modules|dist|build|\.next|coverage|vendor)(/|$)")

EXPORT_RE = re.compile(
    r"^[+-]export\s+(?:default\s+)?(?:async\s+)?"
    r"(?:function|const|let|var|class|type|interface|enum)\s+([A-Za-z_$][\w$]*)"
    r"|^[+-]pub\s+(?:fn|struct|trait|enum)\s+([A-Za-z_][\w]*)"
    r"|^[+-]func\s+([A-Z][\w]*)"
)


def git(args: list[str]) -> str:
    out = subprocess.run(["git", *args], capture_output=True, text=True)
    return out.stdout if out.returncode == 0 else ""


def diff_path(header: str) -> str | None:
    """Path from a `---`/`+++` header line, or None for `/dev/null`.

    A **deleted** file's `+++` is `/dev/null` and its only name is on the `---`
    line. Reading `+++ b/` alone leaves the parser pointed at the previous file,
    so a deleted module's removed exports are attributed to whichever package
    was parsed before it — a major-bump signal against the wrong package, while
    the package that actually lost the export never appears at all.
    """
    rest = header.strip()
    if rest == "/dev/null":
        return None
    return rest[2:] if rest[:2] in ("a/", "b/") else rest


def default_base() -> str:
    for branch in ("origin/main", "main", "origin/master", "master"):
        base = git(["merge-base", "HEAD", branch]).strip()
        if base:
            return base
    return "HEAD~1"


def owning_package(root: str, rel: str) -> tuple[str, str, bool]:
    """(package dir, package name, published) for the file's nearest manifest."""
    parts = rel.split("/")
    for i in range(len(parts) - 1, 0, -1):
        candidate = "/".join(parts[:i])
        manifest = os.path.join(root, candidate, "package.json")
        if os.path.isfile(manifest):
            try:
                with open(manifest, encoding="utf-8") as handle:
                    data = json.load(handle)
            except (OSError, json.JSONDecodeError):
                return candidate, candidate, False
            return candidate, data.get("name") or candidate, not data.get("private", False)
    return ".", "(root)", False


def has_prose(blob: str) -> bool:
    """True when a changeset carries a body, not just empty frontmatter."""
    body = re.sub(r"^---[\s\S]*?---", "", blob, count=1).strip()
    return len(body) > 20


def detect_convention(root: str) -> tuple[str, list[str]]:
    """(convention name, recent example entries)."""
    changeset_dir = os.path.join(root, ".changeset")
    if os.path.isdir(changeset_dir):
        examples = []
        # Recent merged changesets are deleted on release, so the git history is
        # the only reliable source of house voice for a repo that has shipped.
        log = git(["log", "-40", "--diff-filter=A", "--name-only", "--format=", "--", ".changeset"])
        # `.changeset/README.md` is the Changesets boilerplate, not an example of
        # anyone's house voice. The directory listing below already excludes it;
        # this loop has to as well, or a young repo — one whose last 40 additions
        # still reach the initial commit — hands the reader the boilerplate and
        # tells them to match its voice.
        for path in [p for p in log.splitlines()
                     if p.endswith(".md") and os.path.basename(p) != "README.md"]:
            if len(examples) >= 4:
                break
            # A released changeset is deleted from HEAD, so `git show HEAD:path`
            # returns nothing. Only entries with prose survive — an empty
            # `---\n---` example teaches the reader nothing about house voice and
            # actively muddies the pattern they are asked to match.
            blob = (git(["show", f"HEAD:{path}"]) or "").strip()
            # An unreleased changeset is reachable both from history and from the
            # directory listing below; without this it is printed twice and reads
            # as though the repo repeats itself.
            if has_prose(blob) and blob not in examples:
                examples.append(blob)
        for name in sorted(os.listdir(changeset_dir)):
            if not name.endswith(".md") or name == "README.md" or len(examples) >= 4:
                continue
            with open(os.path.join(changeset_dir, name), encoding="utf-8") as handle:
                blob = handle.read().strip()
            if has_prose(blob) and blob not in examples:
                examples.append(blob)
        return "Changesets (.changeset/*.md)", examples

    for name in ("CHANGELOG.md", "CHANGES.md", "HISTORY.md"):
        path = os.path.join(root, name)
        if os.path.isfile(path):
            with open(path, encoding="utf-8", errors="replace") as handle:
                head = handle.read(2500)
            return f"hand-maintained {name}", [head]

    subjects = [s for s in git(["log", "-20", "--format=%s"]).splitlines() if s]
    conventional = sum(
        1 for s in subjects if re.match(r"^(feat|fix|chore|docs|refactor|test|perf|build|ci)(\(.+\))?!?:", s)
    )
    if subjects and conventional / len(subjects) > 0.6:
        return "Conventional Commits (no changeset system detected)", subjects[:6]

    return "NONE DETECTED", subjects[:6]


def main() -> int:
    base = sys.argv[1] if len(sys.argv) > 1 else default_base()
    root = git(["rev-parse", "--show-toplevel"]).strip() or os.getcwd()
    os.chdir(root)

    added: dict[str, set[str]] = defaultdict(set)
    removed: dict[str, set[str]] = defaultdict(set)
    touched: dict[str, tuple[str, bool]] = {}
    current_pkg: str | None = None
    old_path: str | None = None
    in_hunks = False

    for line in git(["diff", "--unified=0", base, "--"]).splitlines():
        # Headers are read only before a file's first `@@`, so a removed line
        # whose own text begins with `---` cannot be mistaken for one. The file
        # is resolved once here rather than per line, which also spares
        # `owning_package` a filesystem walk for every line of the diff.
        if line.startswith("diff --git "):
            current_pkg, old_path, in_hunks = None, None, False
            continue
        if not in_hunks:
            if line.startswith("--- "):
                old_path = diff_path(line[4:])
            elif line.startswith("+++ "):
                current_pkg = None
                path = diff_path(line[4:]) or old_path
                if path and not SKIP_RE.search(f"/{path}"):
                    pkg_dir, pkg_name, published = owning_package(root, path)
                    touched.setdefault(pkg_dir, (pkg_name, published))
                    # Exports declared in a test file are scaffolding, not
                    # public surface, so the package is recorded as touched but
                    # its symbols are not read.
                    if SOURCE_RE.search(path) and not TEST_RE.search(path):
                        current_pkg = pkg_dir
            elif line.startswith("@@"):
                in_hunks = True
            continue
        if current_pkg is None:
            continue
        m = EXPORT_RE.match(line)
        if not m:
            continue
        symbol = next((g for g in m.groups() if g), None)
        if not symbol:
            continue
        (added if line.startswith("+") else removed)[current_pkg].add(symbol)

    print(f"base: {base}\n")
    if not touched:
        print("No files changed against this base.")
        return 0

    print("── packages touched ──")
    for pkg_dir, (name, published) in sorted(touched.items()):
        flag = "published" if published else "private"
        print(f"  [{flag:>9}] {name}  ({pkg_dir})")

    print("\n── public surface delta ──")
    any_surface = False
    for pkg_dir, (name, published) in sorted(touched.items()):
        new = added[pkg_dir] - removed[pkg_dir]
        gone = removed[pkg_dir] - added[pkg_dir]
        changed = added[pkg_dir] & removed[pkg_dir]
        if not (new or gone or changed):
            continue
        any_surface = True
        print(f"\n  {name} ({'published' if published else 'private'})")
        if gone:
            print(f"    REMOVED   {', '.join(sorted(gone))}")
        if changed:
            print(f"    CHANGED   {', '.join(sorted(changed))}   (definition line edited)")
        if new:
            print(f"    ADDED     {', '.join(sorted(new))}")

    if not any_surface:
        print(
            "  None. No exported symbol was added, removed or redefined.\n"
            "  On surface alone this is a patch — but a changed default or behaviour "
            "under an unchanged signature is invisible here. Check before concluding."
        )

    convention, examples = detect_convention(root)
    print(f"\n── release convention ──\n  {convention}")
    if convention == "NONE DETECTED":
        print(
            "  No changeset directory, changelog file, or consistent commit convention.\n"
            "  Ask before introducing one — do not create .changeset/ unprompted."
        )
    for i, example in enumerate(examples[:3], 1):
        snippet = "\n    ".join(example.splitlines()[:8])
        print(f"\n  example {i}:\n    {snippet}")

    print(
        "\nMatch the voice of the examples above. Bump from the surface delta, not the "
        "diff size: removed or narrowed = major, added only = minor, no delta = patch."
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())