Automated Changeset Generator & History Auditor

Use this before a release to find changes without documentation. It helps you create changesets that explain the benefits of your work to users.

How to use

Use this to ensure all functional changes are captured before a release.

Prompt

Scan git history, identify undocumented changes, and generate corresponding changesets.

Step 0: Detect Repository Type

Before anything else, detect the repository type. Run:

cat package.json | grep '"workspaces"'
ls pnpm-workspace.yaml 2>/dev/null || true
  • Workspaces found → MONOREPO mode.
  • No workspaces → SINGLE-PACKAGE mode.

Store the detected mode. Apply the correct rules in every step below.

Step 1: Find the Baseline (Prevent Duplication)

We do not use git tags. The baseline is the most recent release commit.

The core evidence of a release commit is in the files it changes — not the commit message.
Running pnpm changeset version always produces exactly these three file operations:

Fingerprint 1 — Deleted .changeset/*.md files.
The tool consumes all pending changeset files. A real release commit always deletes them.

Fingerprint 2 — Modified CHANGELOG.md files.
The tool writes a new version section into each affected changelog.

Fingerprint 3 — Modified package.json files.
The tool bumps version numbers in each affected package.

The commit message is only a fast pre-filter. It has been inconsistent in the past and must not be used as the sole source of truth.

Detection algorithm — run in this order:

# Pre-filter: narrow candidates by commit message
git log --grep="chore(release)\|chore: version packages\|chore(deps): version" \
  -n 1 --format="%H"

If the pre-filter returns a hash, confirm it is a real release:

# Confirm Fingerprint 1: commit deleted .changeset/*.md files
git show <HASH> --stat | grep "\.changeset/.*\.md"

# Confirm Fingerprint 2: commit modified CHANGELOG.md
git show <HASH> --stat | grep "CHANGELOG"

If both confirmations pass → this hash is the baseline.

If the pre-filter finds nothing, or the candidate fails confirmation, use the fallback:

# Fallback: find the most recent commit that deleted .changeset/*.md files
git log --diff-filter=D --name-only --format="%H %s" -- '.changeset/*.md' | head -1

Use the confirmed hash as the baseline. Process only <HASH>..HEAD.

Step 1b: Verify History Integrity (CRITICAL)

Before proceeding, perform a "Sanity Check" to prevent Version Regression:

  1. Pick a random changed package from the monorepo.
  2. Check its version in the Baseline hash: git show <HASH>:packages/<name>/package.json | grep version.
  3. Check its current version: cat packages/<name>/package.json | grep version.

Safety Rule:
If the current version is LOWER than the version in the Baseline commit, the history is corrupted (someone did a destructive rollback).
STOP IMMEDIATELY. Do not generate changesets. Report the conflict to the user.

If no hash is found by any method → the repo has no release yet. Process all commits.

Convention going forward: All release commits must use the exact subject chore(release): releaseAutomat. This makes the pre-filter reliable and avoids the need for fallback logic.

Get the list of changed commits and files in scope:

git log <HASH>..HEAD --pretty=format:"%h %s"
git log <HASH>..HEAD --name-only --format=""

Helper script (optional)

If the total commits in scope is more than ~20, use deterministic batching and the attached helper script missing_changesets_phase1_generate_batch.py (see attachments in this prompt). It is designed for Phase 1 raw-capture and intentionally includes formatting-only commits so that X (commit-body - bullets) and Y (generated bullets) stay comparable. Phase 2 removes the noise.

Step 2: Identify Changed Packages

MONOREPO mode

Map each changed file to its package folder. Workspace folders are typically: packages/, apps/, sites/, tools/.

For each changed file, read the package.json inside the matching folder to get the exact package name (e.g. "name": "@bits/ui").

Always skip (Noise Filter):

  • Lockfiles (pnpm-lock.yaml, package-lock.json)
  • .gitignore
  • Pure Formatting Changes: Changes that only affect whitespace, semicolons, quotes, or indentation (e.g. Prettier/linting) MUST BE IGNORED. If a commit only contains formatting, do not create a changeset entry for it.

SINGLE-PACKAGE mode

All changed files belong to the one package. Read the package name from root:

cat package.json | grep '"name"'

Always skip (Noise Filter):

  • Lockfiles
  • .gitignore
  • Pure Formatting Changes: Same as above. Skip any commit that only provides code style/formatting with no functional or visual user benefit.

Step 3: Determine Version Bumps

MANDATORY CHECK — Do this first, before applying any rule:

For each package, read its current version:

cat <package-path>/package.json | grep '"version"'
  • If the version is 0.0.xset major unconditionally and skip all other rules. This is an initial release transitioning to 1.0.0.
  • Otherwise → apply the highest matching rule from the table below:
Rule Bump
Any commit contains BREAKING CHANGE major
Any commit refers to a major redesign or foundation change major
Any commit starts with feat: (standard feature) minor
All other (fix:, refactor:, perf:, chore:, style:) patch

Step 4: Write Changeset Summaries

Write bullet points that explain the Problem (Cause) and the Fix (Solution). Avoid superficial statements.

Rules (strictly enforced):

  • Audience: End-users. Write like a user story.
  • Informational Value (MANDATORY): Every bullet must tell the user What was changed and Why it matters (problem solved).
    • ❌ "Made data loading more reliable." (Too vague)
    • ✅ "Fixed white screen error during data load by adding fail-safe checks for missing translations."
    • ❌ "Improved visual design."
    • ✅ "Refined timeline spacing to eliminate overlapping text on small phone screens."
  • No Fabrication (CRITICAL): Every claim in a bullet MUST be verifiable in the actual commit diff. Do not add anything you cannot see in git show. If unsure, omit it.
  • Language: British English (GB-EN), Level A1. Keep sentences short but meaningful.
  • Specifics over Generalities: Use concrete terms from the user interface (e.g. "timeline", "footer", "map", "photo gallery") but not technical code terms.
  • Past tense: "Added", "Fixed", "Resolved", "Refined"
  • No Formatting Noise: Ignore commits that only touch whitespace, semicolons, or Prettier rules.
  • No commit hashes, IDs, or Filenames.

Good vs. Bad Quality:

❌ Surface Level (Bad) ✅ Deep Informational (Good)
Improved data loading Fixed app crash on slow internet by adding timeout protection
Added new trip story Added Oman travel story with all chat history and photos
Fixed media display Corrected blurred images by using high-resolution thumbnails
Updated parsing logic Fixed missing messages that were skipped due to incorrect date format
Refined layout Improved readability on mobile by increasing contrast in the footer

Step 5: Create Changeset Files

Location: .changeset/ (always at the root of the repository)

Filename format: <package-slug>-<bump-type>-<unix-timestamp>.md

  • Monorepo: ui-patch-1718000.md, karibik-minor-1718001.md
  • Single-package: app-patch-1718000.md
  • Do not use random words. Do not use commit hashes.

Strict Formatting Rule (CRITICAL):
Every changeset file MUST use YAML frontmatter enclosed in triple dashes (---). Without these, the release tool will fail.

File format:

---
"<package-name>": patch
---

- Simple description with clear value
- Another short description
  • <package-name> must exactly match the name field in the package's package.json.
  • One file per package (monorepo), or one file total (single-package).

Step 6: Verify & Commit

Checklist:

  • Repo type detected correctly (monorepo or single-package)
  • Only commits after the most recent release commit were processed
  • One changeset file per changed package
  • Package name matches package.json exactly
  • Version bumps are correct (patch / minor / major)
  • Formatting: Files start and end frontmatter with ---
  • All text is simple A1 British English
  • Informational Value: No vague descriptions like "improved design"
  • No commit hashes anywhere in the text
  • Files are in .changeset/ only

Stage and commit — include a body listing every package and its bump type:

git add .changeset/
git commit \
  -m $'chore(changeset): releaseIntent' \
  -m $'- @scope/package-a: minor\n- package-name: major\n- package-name: patch'

Body rule: List every package with its bump type, separated by \n in the second -m flag. The list MUST be sorted alphabetically. Git automatically inserts a blank line between the subject (-m 1) and body (-m 2).

Common Mistakes

❌ Wrong ✅ Correct
Exact string match for baseline Broad --grep pattern for all variants
Scanning commits before last release Start strictly after the most recent release commit
American spelling (color) British (colour)
Technical jargon User impact only
Complex sentences A1 level, short sentences
Commit hashes in text Plain text only
One changeset for all packages One file per package (monorepo)
Wrong package name Must match package.json exactly
No commit body Body must list all packages + bump types
Unsorted commit body List of packages MUST be alphabetical

Attachments

missing_changesets_phase1_generate_batch
#!/usr/bin/env python3
import argparse
import glob
import json
import os
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path

def sh(cmd, *, text=True):
    return subprocess.check_output(cmd, text=text)

def git(args):
    return sh(['git', *args])

LOCKFILE_NAMES = {
    'pnpm-lock.yaml',
    'package-lock.json',
    'yarn.lock',
    'npm-shrinkwrap.json',
}


def is_lockfile(path: str) -> bool:
    base = os.path.basename(path)
    return base in LOCKFILE_NAMES


def is_ignored_file(path: str) -> bool:
    return path == '.gitignore' or is_lockfile(path)


def slugify_package_name(name: str) -> str:
    # '@bits/prettier-config-svelte' -> 'bits-prettier-config-svelte'
    # 'content-sync' -> 'content-sync'
    s = name.strip()
    if s.startswith('@'):
        s = s[1:]
    s = s.replace('/', '-')
    s = re.sub(r'[^a-zA-Z0-9._-]+', '-', s)
    s = re.sub(r'-{2,}', '-', s).strip('-')
    return s.lower()


def parse_changed_packages_list(path: Path):
    # Lines: '- @bits/ui (packages/ui)'
    pkgs = []
    pat = re.compile(r'^-\s+([^ ]+)\s+\(([^)]+)\)\s*$')
    for line in path.read_text(encoding='utf-8').splitlines():
        line = line.strip()
        if not line:
            continue
        m = pat.match(line)
        if not m:
            raise SystemExit(f'changed-packages line did not match expected format: {line!r}')
        pkgs.append((m.group(1), m.group(2)))
    return pkgs


def read_pkg_name(scope_path: str) -> str:
    pj = Path(scope_path) / 'package.json'
    if not pj.exists():
        raise FileNotFoundError(f'missing package.json at {pj}')
    data = json.loads(pj.read_text(encoding='utf-8'))
    name = data.get('name')
    if not name:
        raise ValueError(f'package.json has no name: {pj}')
    return name


def read_pkg_version(scope_path: str) -> str:
    pj = Path(scope_path) / 'package.json'
    data = json.loads(pj.read_text(encoding='utf-8'))
    v = data.get('version')
    if not v:
        raise ValueError(f'package.json has no version: {pj}')
    return v


def bump_rank(b: str) -> int:
    return {'patch': 0, 'minor': 1, 'major': 2}[b]


def bump_from_commit_message(subject: str, body: str) -> str:
    full = subject + "\n" + body
    if 'BREAKING CHANGE' in full:
        return 'major'
    if re.search(r'\b(redesign|foundation)\b', full, flags=re.I):
        return 'major'
    if subject.startswith('feat:') or subject.startswith('feat('):
        return 'minor'
    return 'patch'


def parse_subject_prefix(subject_line: str):
    # Handle autosquash prefixes
    raw = subject_line.strip()

    fixup = False
    if raw.startswith('fixup! '):
        fixup = True
        raw = raw[len('fixup! '):].lstrip()

    # Conventional prefix: type(scope): or type:
    m = re.match(r'^([a-zA-Z]+)(\([^)]*\))?:\s*(.*)$', raw)
    if not m:
        return None

    typ = m.group(1)
    scope = m.group(2) or ''
    rest = m.group(3) or ''

    # Normalize types
    if fixup:
        typ = 'fix'
    elif typ == 'style':
        typ = 'chore'

    prefix = f"{typ}{scope}:"
    return prefix, rest


def strip_leading_prefix(text: str) -> str:
    # Remove any leading 'type(scope):' or 'type:' from subject text when using subject as raw bullet text.
    return re.sub(r'^[a-zA-Z]+(\([^)]*\))?:\s*', '', text.strip())


def commit_paths(commit: str):
    out = git(['show', '--name-only', '--format=', commit])
    paths = [p.strip() for p in out.splitlines() if p.strip()]
    return paths


def commit_message(commit: str):
    msg = git(['show', '-s', '--format=%B', commit])
    lines = msg.splitlines()
    subject = ''
    for ln in lines:
        if ln.strip():
            subject = ln.rstrip('\n')
            break
    body = msg[len(subject):].lstrip('\n') if subject else msg
    return subject, body


def scopes_by_path(paths):
    scopes = set()
    for p in paths:
        m = re.match(r'^(packages|sites|tools|apps)/[^/]+', p)
        if m:
            scopes.add(m.group(0))
    return sorted(scopes)


def ensure_no_existing_changesets(ts: int):
    existing = glob.glob(f'.changeset/*-{ts}.md')
    if existing:
        raise SystemExit(f'Refusing to overwrite existing .changeset files for ts={ts}: {existing[:3]}...')


def write_changeset_file(path: Path, package_name: str, bump: str, bullets):
    fm = f"---\n\"{package_name}\": {bump}\n---\n\n"
    content = fm + "\n".join(bullets) + "\n"
    path.write_text(content, encoding='utf-8')


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--batch', required=True)
    ap.add_argument('--timestamp', required=True, type=int)
    ap.add_argument('--changed-packages', required=True)
    ap.add_argument('--out-dir', required=True)
    ap.add_argument('--baseline', required=True)
    ap.add_argument('--write-to-changeset', action='store_true')
    args = ap.parse_args()

    batch_path = Path(args.batch)
    ts = args.timestamp
    out_dir = Path(args.out_dir)
    changed_list = Path(args.changed_packages)

    commits = [l.strip() for l in batch_path.read_text(encoding='utf-8').splitlines() if l.strip()]
    if not commits:
        raise SystemExit('empty batch list')

    changed_pkgs = parse_changed_packages_list(changed_list)
    scope_to_pkgname = {scope: read_pkg_name(scope) for _, scope in changed_pkgs}

    # Accumulators
    pkg_bullets = {}  # pkgName -> [bullet, ...]
    pkg_bump = {}     # pkgName -> bump
    commit_index = []

    for h in commits:
        short = git(['rev-parse', '--short', h]).strip()
        subject, body = commit_message(h)
        prefix_parsed = parse_subject_prefix(subject)
        if not prefix_parsed:
            raise SystemExit(f'commit {short} subject does not match conventional prefix: {subject!r}')
        cc_prefix, subject_rest = prefix_parsed

        paths = commit_paths(h)
        # Phase 1 "raw capture" is completeness-first:
        # - Do NOT skip commits based on heuristics (formatting-only, lockfile-only, etc.)
        # - We still ignore lockfile/.gitignore paths when mapping to package scopes so they land in REPO_ONLY.
        paths_non_ignored = [p for p in paths if not is_ignored_file(p)]

        scopes = scopes_by_path(paths_non_ignored)
        if not scopes:
            scopes = ['REPO_ONLY']

        body_bullets_raw = [ln.strip() for ln in body.splitlines() if ln.startswith('- ')]
        bullet_texts = []
        if body_bullets_raw:
            for ln in body_bullets_raw:
                bullet_texts.append(ln[2:].rstrip())
        else:
            bullet_texts.append(strip_leading_prefix(subject))

        commit_index.append({
            'hash': h,
            'short': short,
            'status': 'included',
            'scopes': scopes,
            'prefix': cc_prefix,
            'bullets_in_body': len(body_bullets_raw),
            'bullets_generated': len(bullet_texts),
        })

        for scope in scopes:
            if scope == 'REPO_ONLY':
                pkg_name = '@bits/repo'
                scope_path = None
            else:
                pkg_name = scope_to_pkgname.get(scope)
                if not pkg_name:
                    raise SystemExit(f'could not map scope {scope!r} to package name (missing package.json?)')
                scope_path = scope

            v = '0.0.0'
            if scope_path is not None:
                v = read_pkg_version(scope_path)
            bump = 'major' if re.match(r'^0\.0\.\d+$', v) else bump_from_commit_message(subject, body)
            prev = pkg_bump.get(pkg_name, 'patch')
            pkg_bump[pkg_name] = bump if bump_rank(bump) > bump_rank(prev) else prev

            out = pkg_bullets.setdefault(pkg_name, [])
            for bt in bullet_texts:
                out.append(f"- [{short}] {cc_prefix} {bt}")

    out_dir.mkdir(parents=True, exist_ok=False)

    written = []
    for pkg_name, bullets in sorted(pkg_bullets.items(), key=lambda kv: kv[0]):
        bump = pkg_bump.get(pkg_name, 'patch')
        slug = 'repo' if pkg_name == '@bits/repo' else slugify_package_name(pkg_name)
        fname = f"{slug}-{bump}-{ts}.md"
        p_out = out_dir / fname
        write_changeset_file(p_out, pkg_name, bump, bullets)
        written.append(str(p_out))

        if args.write_to_changeset:
            ensure_no_existing_changesets(ts)
            p_repo = Path('.changeset') / fname
            write_changeset_file(p_repo, pkg_name, bump, bullets)

    (out_dir / 'commit-index.json').write_text(json.dumps(commit_index, indent=2), encoding='utf-8')

    print(json.dumps({
        'batch': str(batch_path),
        'timestamp': ts,
        'commits_total': len(commits),
        'commits_included': sum(1 for c in commit_index if c.get('status') == 'included'),
        'packages_created': sorted(pkg_bullets.keys()),
        'changeset_files_count': len(written),
    }, indent=2))


if __name__ == '__main__':
    main()