Automated Changeset Generator & History Auditor
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 -1Use 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:
- Pick a random changed package from the monorepo.
- Check its version in the Baseline hash:
git show <HASH>:packages/<name>/package.json | grep version. - 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.x→ setmajorunconditionally and skip all other rules. This is an initial release transitioning to1.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 thenamefield in the package'spackage.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.jsonexactly - 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()