diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index 64f7e74d1..5a2fad7ed 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -3,6 +3,15 @@ # Usage: generate-notes.sh [previous-tag] # If previous-tag is omitted, uses the tag before new-tag. # +# Left out of the notes: +# - release bump commits +# - dev-dependency bumps and Claude Code hook changes (scopes `deps-dev`, `hooks`) +# - commits that change only AGENTS.md, CHANGELOG.md, dotfiles at the repository +# root (.gitignore, .trivyignore; not .dockerignore or .env.example), or files +# under .claude/, .devin/ or .husky/ +# - commits that change only `devDependencies` or `overrides` in the root +# package.json, and in package-lock.json only packages marked `"dev": true` +# # Breaking changes are detected from the *merged PR* — a `breaking-change` label # or a `BREAKING CHANGE:` footer in the PR body — as well as from the commit # itself (a `!` type marker or a `BREAKING CHANGE:` footer). The PR is the @@ -22,25 +31,26 @@ PREV_TAG="${2:-$(git describe --tags --abbrev=0 --match "v[0-9]*" "$NEW_TAG"^ 2> if [ -n "$PREV_TAG" ]; then RANGE="${PREV_TAG}..${NEW_TAG}" else + # First server release: list every commit reachable from NEW_TAG. RANGE="$NEW_TAG" fi -# Write one record per non-merge commit as `\x1f`, records -# NUL-separated, to a temp file. A file (not a pipe) is used because the Python -# program is fed to `python3 -` on stdin via the heredoc. Release commits are -# filtered out in Python. +# Write one record per non-merge commit as `\x1f\x1f`, +# records NUL-separated, to a temp file. A file (not a pipe) is used because the +# Python program is fed to `python3 -` on stdin via the heredoc. Commits left +# out of the notes are filtered in Python. NOTES_INPUT="$(mktemp)" trap 'rm -f "$NOTES_INPUT"' EXIT -git log --no-merges --format='%s%x1f%b%x00' "$RANGE" > "$NOTES_INPUT" +git log --no-merges --format='%H%x1f%s%x1f%b%x00' "$RANGE" > "$NOTES_INPUT" python3 - "$NOTES_INPUT" << 'PYTHON' import sys, re, os, json, shutil, subprocess -with open(sys.argv[1], "rb") as fh: - raw = fh.read().decode("utf-8", "replace") -records = [r for r in raw.split("\x00") if r.strip()] +with open(sys.argv[1], "rb") as log_file: + log_text = log_file.read().decode("utf-8", "replace") +records = [record for record in log_text.split("\x00") if record.strip()] -# Category config: (prefix, heading) +# Category config: (commit type, heading) categories = [ ("feat", "Features"), ("fix", "Bug Fixes"), @@ -50,15 +60,17 @@ categories = [ ("eval", "Evals"), ("chore", "Maintenance"), ] -cat_map = {c[0]: c[1] for c in categories} -cat_order = [c[0] for c in categories] +heading_by_type = dict(categories) +type_order = [commit_type for commit_type, _ in categories] -buckets = {c[0]: [] for c in categories} +buckets = {commit_type: [] for commit_type in type_order} other = [] breaking = [] # Pattern: type(scope)!: description — scope and the breaking `!` are optional. -pat = re.compile(r'^([a-z]+)(?:\(([^)]+)\))?(!)?:\s+(.+)$') +subject_pat = re.compile( + r'^(?P[a-z]+)(?:\((?P[^)]+)\))?(?P!)?:\s+(?P.+)$' +) # Release bump commits (server "release:" and CLI "release(cli):") are noise. release_pat = re.compile(r'^release(\(cli\))?:') # A BREAKING CHANGE footer — conventional commits allows a space or hyphen. @@ -70,6 +82,8 @@ pr_pat = re.compile(r'\(#(\d+)\)\s*$') # run without it skips lookups and falls back to commit-only signals (the # documented offline behavior) instead of firing unauthenticated calls. GH = shutil.which("gh") if os.environ.get("GH_TOKEN") else None +# Set by GitHub Actions. A local run leaves it empty, and `gh` then uses the +# checkout's own remote. REPO = os.environ.get("GITHUB_REPOSITORY", "") BREAKING_LABEL = "breaking-change" _pr_cache = {} @@ -108,38 +122,162 @@ def pr_info(pr_number): _pr_cache[pr_number] = info return info -for rec in records: - subject, _, body = rec.partition("\x1f") +# Commits with these scopes change nothing a user installs. Hook commits also +# change their tests in src/__tests__/, so a path rule alone would miss them. +CONTRIBUTOR_ONLY_SCOPES = {"deps-dev", "hooks"} + +# Root dotfiles users depend on: .dockerignore decides what the image build +# copies, and DEPLOY.md has deployers copy .env.example. +USER_FACING_DOTFILES = {".dockerignore", ".env.example"} + +# A commit that changes only these paths changes nothing a user installs. +def is_contributor_only_path(path): + # The "/" check leaves out dot-folders such as .github/, so CI changes stay + # in the notes. + is_root_dotfile = path.startswith(".") and "/" not in path and path not in USER_FACING_DOTFILES + return ( + is_root_dotfile + or path in ("AGENTS.md", "CHANGELOG.md") + or path.startswith((".claude/", ".devin/", ".husky/")) + ) + +# No paths keeps the commit listed, so a failed lookup lists it with a warning. +def changed_paths(commit_hash): + result = subprocess.run( + ["git", "diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", commit_hash], + capture_output=True, text=True, errors="replace", + ) + if result.returncode != 0: + warn(f"git diff-tree {commit_hash} failed (exit {result.returncode}): " + f"{result.stderr.strip()} — listing the commit") + return [] + return [path for path in result.stdout.split("\x00") if path] + +ROOT_MANIFESTS = {"package.json", "package-lock.json"} +DEV_ONLY_MANIFEST_FIELDS = {"devDependencies", "overrides"} + +def read_json_at(revision, path): + result = subprocess.run( + ["git", "show", f"{revision}:{path}"], capture_output=True, text=True, errors="replace" + ) + if result.returncode != 0: + return None + try: + json_value = json.loads(result.stdout) + except json.JSONDecodeError as error: + warn(f"{path} at {revision} is not valid JSON ({error}) — listing the commit") + return None + if not isinstance(json_value, dict): + warn(f"{path} at {revision} is not a JSON object — listing the commit") + return None + return json_value + +# Tells a missing key from one set to null, so adding a null value is a change. +_MISSING = object() + +def changed_keys(old_mapping, new_mapping): + keys = old_mapping.keys() | new_mapping.keys() + return [key for key in keys if old_mapping.get(key, _MISSING) != new_mapping.get(key, _MISSING)] + +# Security updates and overrides can use the `deps` scope even when every package +# they move is a dev dependency. The lockfile marks a package that only +# devDependencies reach with `"dev": true`. A manifest missing or unreadable at +# either revision counts as a user change. +def changes_only_dev_dependencies(commit_hash, paths): + changes_only_manifests = bool(paths) and set(paths) <= ROOT_MANIFESTS + if not changes_only_manifests: + return False + + parent = f"{commit_hash}^" + old_manifest = read_json_at(parent, "package.json") + new_manifest = read_json_at(commit_hash, "package.json") + old_lock = read_json_at(parent, "package-lock.json") + new_lock = read_json_at(commit_hash, "package-lock.json") + if None in (old_manifest, new_manifest, old_lock, new_lock): + return False + if not set(changed_keys(old_manifest, new_manifest)) <= DEV_ONLY_MANIFEST_FIELDS: + return False + + # The lockfile's `packages` map keys each installed package by its install + # path (`node_modules/foo`). Its "" entry is the project itself, which + # copies package.json's dependency lists (npm leaves out `overrides`). + old_packages = old_lock.get("packages") + new_packages = new_lock.get("packages") + has_package_maps = isinstance(old_packages, dict) and isinstance(new_packages, dict) + + # A lockfile without that map (npm 6's lockfileVersion 1) counts as a user change. + if not has_package_maps: + return False + for install_path in changed_keys(old_packages, new_packages): + lock_entries = [ + packages[install_path] + for packages in (old_packages, new_packages) + if install_path in packages + ] + # An entry that isn't an object counts as a user change. + if not all(isinstance(lock_entry, dict) for lock_entry in lock_entries): + return False + if install_path == "": + root_fields = changed_keys(old_packages.get("", {}), new_packages.get("", {})) + if not set(root_fields) <= DEV_ONLY_MANIFEST_FIELDS: + return False + continue + if not all(lock_entry.get("dev") is True for lock_entry in lock_entries): + return False + return True + +for record in records: + commit_hash, _, rest = record.partition("\x1f") + # Every record after the first starts with the newline git log writes after + # each NUL terminator. + commit_hash = commit_hash.strip() + subject, _, body = rest.partition("\x1f") subject = subject.strip() if not subject or release_pat.match(subject): continue - match = pat.match(subject) - pr_num = pr_pat.search(subject) - pr = pr_info(pr_num.group(1)) if pr_num else None + match = subject_pat.match(subject) + has_contributor_only_scope = bool(match) and match["scope"] in CONTRIBUTOR_ONLY_SCOPES + # The scope check comes first: a failed path lookup warns that the commit + # is listed. + if has_contributor_only_scope: + continue + paths = changed_paths(commit_hash) + # An empty commit has no paths, and all() of nothing is True; keep it listed. + changes_only_contributor_files = bool(paths) and all( + is_contributor_only_path(path) for path in paths + ) + if changes_only_contributor_files: + continue + if changes_only_dev_dependencies(commit_hash, paths): + continue + + pr_match = pr_pat.search(subject) + pr = pr_info(pr_match.group(1)) if pr_match else None # Categorize from the commit subject. if match: - typ, scope, _bang, desc = match.group(1, 2, 3, 4) - desc = desc[0].upper() + desc[1:] # capitalize - entry = f"- **{scope}:** {desc}" if scope else f"- {desc}" - (buckets[typ] if typ in buckets else other).append(entry) + commit_type, scope, description = match.group("type", "scope", "description") + description = description[0].upper() + description[1:] + entry = f"- **{scope}:** {description}" if scope else f"- {description}" + buckets.get(commit_type, other).append(entry) else: - desc = subject[0].upper() + subject[1:] if subject else subject - other.append(f"- {desc}") + description = subject[0].upper() + subject[1:] + other.append(f"- {description}") # Breaking signals, in preference order for the descriptive line: - # PR body footer > commit body footer > subject (when only `!`/label flags). - commit_footer = breaking_pat.search(body or "") + # PR body footer > commit body footer > description (when only `!`/label flags). + commit_footer = breaking_pat.search(body) pr_footer = breaking_pat.search(pr["body"]) if pr else None has_label = bool(pr and BREAKING_LABEL in pr["labels"]) - if (match and match.group(3)) or commit_footer or pr_footer or has_label: + has_breaking_marker = bool(match and match["breaking"]) + if has_breaking_marker or commit_footer or pr_footer or has_label: if pr_footer: note = pr_footer.group(1).strip() elif commit_footer: note = commit_footer.group(1).strip() else: - note = desc + note = description breaking.append(f"- {note}") # Collapse identical breaking lines. A follow-up PR (a docs PR documenting the @@ -149,20 +287,20 @@ for rec in records: breaking = list(dict.fromkeys(breaking)) # Output — BREAKING CHANGES lead so they're impossible to miss. -out = [] +output_lines = [] if breaking: - out += ["### ⚠ BREAKING CHANGES", ""] + breaking + output_lines += ["### ⚠ BREAKING CHANGES", ""] + breaking -for typ in cat_order: - if buckets[typ]: - if out: - out.append("") - out += [f"### {cat_map[typ]}", ""] + buckets[typ] +for commit_type in type_order: + if buckets[commit_type]: + if output_lines: + output_lines.append("") + output_lines += [f"### {heading_by_type[commit_type]}", ""] + buckets[commit_type] if other: - if out: - out.append("") - out += ["### Other Changes", ""] + other + if output_lines: + output_lines.append("") + output_lines += ["### Other Changes", ""] + other -print("\n".join(out) if out else "No notable changes.") +print("\n".join(output_lines) if output_lines else "No notable changes.") PYTHON diff --git a/AGENTS.md b/AGENTS.md index 2074301fd..3df874e02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -945,18 +945,21 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal): `vi.mock(path, { spy: true })` to keep the real implementation. **Exception — shell scripts outside vitest's include paths.** The remote image's s6 init scripts under `rootfs/etc/s6-overlay/scripts/` - are the `vault-mcp` server's boot chain for the `:remote` target, and - the Claude Code hook sits in `.claude/hooks/`. Neither is a TypeScript - module, and vitest's include paths (`src/`, `cli/src/`, `scripts/`) - reach neither folder. The init-script tests live in + are the `vault-mcp` server's boot chain for the `:remote` target, the + Claude Code hook sits in `.claude/hooks/`, and the release scripts sit + in `.github/scripts/`. None is a TypeScript module, and vitest's + include paths (`src/`, `cli/src/`, `scripts/`) reach none of those + folders. The init-script tests live in `src/vault-mcp/__tests__/` (`init-check-auth.test.ts`, `init-obsidian-login.test.ts`, `init-first-sync.test.ts`, `init-setup-user.test.ts`, `init-setup-vault.test.ts`, `print-derived-env.test.ts`, which covers the derivation that - `init-derive-env` publishes), and the hook's test in `src/__tests__/` - (`install-deps.test.ts`). These script + `init-derive-env` publishes), and the hook's and release scripts' + tests in `src/__tests__/` (`install-deps.test.ts`, + `generate-notes.test.ts`, `update-changelog.test.ts`). These script tests run the real script under its own shell (`sh` for the init - scripts, `bash` for the hook) with stub binaries on `PATH`, and name + scripts, `bash` for the others) with stub binaries on `PATH` or, for + the release-notes script, a temporary git repository, and name the script they cover — don't move them next to the scripts or widen vitest's include for them. Whole-image behaviour (the init chain's ordering, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ec2b8ed0..7d2f798be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -224,19 +224,39 @@ the allowance states why. ## Release Process -Releases are cut by the maintainer. Two paths: +Releases are cut by the maintainer, in one of two ways: - **Manual Release:** Actions tab → "Manual Release" → choose - `patch`/`minor`/`major`. Bumps version, deploys, creates GitHub Release. -- **Tag push:** merge a version-bump PR into `main`, then - `git tag v && git push --tags` + `patch`/`minor`/`major`. The workflow bumps the version, deploys, and + creates the GitHub Release. +- **Tag push:** merge a version-bump PR into `main`, then run + `git tag v && git push --tags`. The tag push deploys and creates + the GitHub Release. -Direct commits to `main` are blocked by a branch ruleset — all changes, -version bumps included, land via PR. +Direct commits to `main` are blocked by a branch ruleset — all changes, a tag +push's version bump included, land via PR. Only the release workflows push to +`main` directly, for their version-bump and CHANGELOG commits. The CLI releases separately: Actions tab → "Release CLI" (see [The `cli/` Package](#the-cli-package)). +What each release's notes list: + +- **Server releases** (`.github/scripts/generate-notes.sh`): every commit + since the last release, except: + - release bumps + - dev-dependency bumps: commits with the `deps-dev` scope, and commits that + change only `devDependencies` or `overrides` in the root `package.json`, + and in `package-lock.json` only packages marked `"dev": true` + - Claude Code hook changes (commit scope `hooks`) + - commits that change only `AGENTS.md`, `CHANGELOG.md`, dotfiles at the + repository root (`.gitignore`, `.trivyignore`; not `.dockerignore`, which + decides what the image build copies, or `.env.example`, which DEPLOY.md + has deployers copy), or files under `.claude/`, + `.devin/` or `.husky/` +- **CLI releases** (`.github/workflows/cli_release.yml`): every commit that + touches `cli/`, dependency bumps included (`deps-dev` too). + See the [DEPLOY.md CI/CD section](./DEPLOY.md#cicd) for details on each workflow. ## Railway template diff --git a/DEPLOY.md b/DEPLOY.md index aca6c453c..fffa40992 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -409,7 +409,7 @@ Both halves come from the dedicated deploy keypair set up in [Prerequisites](#pr **Tag push** — merge a version-bump PR into `main`, then tag the merge commit: `git tag v && git push --tags`. The tag push triggers `auto_release.yml`, which deploys and creates the release. -Direct commits to `main` are blocked by a branch ruleset — every change, version bumps included, lands via PR. Release automation is the only actor that pushes to `main` directly (the changelog and version-bump commits in the workflows above). +Direct commits to `main` are blocked by a branch ruleset — every change, a tag push's version bump included, lands via PR. Release automation is the only actor that pushes to `main` directly (the changelog and version-bump commits in the workflows above). ### Rotating SSH keys diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts new file mode 100644 index 000000000..b71dd24d0 --- /dev/null +++ b/src/__tests__/generate-notes.test.ts @@ -0,0 +1,536 @@ +import { execFileSync, spawnSync } from "node:child_process" +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { dirname, join, resolve } from "node:path" + +import { describe, expect, it, onTestFinished } from "vitest" + +/** + * Behavioral spec for the server release-notes generator + * (.github/scripts/generate-notes.sh): which commits between two tags the + * notes list, and how they are grouped. The real script runs under `bash` with + * real git and python3, in a fresh repository per test. GH_TOKEN is never set, + * so the script makes no pull-request lookups. + */ + +const SCRIPT_PATH = resolve(import.meta.dirname, "../../.github/scripts/generate-notes.sh") + +const runnerPath = (): string => { + const path = process.env.PATH + + if (!path) throw new Error("the release-notes tests need a PATH") + return path +} + +type PackageManifest = Record + +type Lockfile = { + name: string + lockfileVersion: number + packages: Record> +} + +/** The project the first release ships: one dependency the app installs + * (zod) and one dev dependency (vitest). */ +const BASE_MANIFEST: PackageManifest = { + name: "fixture", + version: "1.0.0", + dependencies: { zod: "^1.0.0" }, + devDependencies: { vitest: "^1.0.0" }, +} + +const BASE_LOCKFILE: Lockfile = { + name: "fixture", + lockfileVersion: 3, + packages: { + "": { + name: "fixture", + version: "1.0.0", + dependencies: { zod: "^1.0.0" }, + devDependencies: { vitest: "^1.0.0" }, + }, + "node_modules/zod": { version: "1.0.0" }, + "node_modules/vitest": { version: "1.0.0", dev: true }, + }, +} + +const toJsonFile = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n` + +/** File contents by repository path; null deletes the file. */ +type FileChanges = Record + +type NotesRun = { status: number | null; stdout: string; stderr: string } + +type ReleaseRepo = { + /** Commits the changes with the message; no changes makes an empty commit. + * Returns the commit's hash. */ + commit: (message: string, changes: FileChanges) => string + /** Deletes the commit's root tree object, so git still lists the commit but + * cannot diff it. */ + deleteRootTree: (commitHash: string) => void + /** Tags HEAD as the release under test and runs the script for it. */ + generateNotes: () => NotesRun +} + +/** A repository whose first commit, tagged v0.1.0, holds the base manifests + * and a source file. */ +const createReleaseRepo = (): ReleaseRepo => { + // realpath: macOS's tmpdir is a symlink. + const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "generate-notes-"))) + onTestFinished(() => rmSync(tempDir, { recursive: true, force: true })) + const repoDir = join(tempDir, "repo") + + // Built from scratch, so the runner's git config (signing, hooks) and + // GH_TOKEN never reach git or the script. + const env = { + PATH: runnerPath(), + HOME: tempDir, + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "Fixture", + GIT_AUTHOR_EMAIL: "fixture@example.com", + GIT_COMMITTER_NAME: "Fixture", + GIT_COMMITTER_EMAIL: "fixture@example.com", + } + const git = (...args: string[]): string => { + return execFileSync("git", ["-C", repoDir, ...args], { env, encoding: "utf8", stdio: "pipe" }) + } + + const commit = (message: string, changes: FileChanges): string => { + for (const [path, content] of Object.entries(changes)) { + const filePath = join(repoDir, path) + + if (content === null) { + rmSync(filePath) + continue + } + mkdirSync(dirname(filePath), { recursive: true }) + writeFileSync(filePath, content) + } + git("add", "--all") + git("commit", "--quiet", "--allow-empty", "--message", message) + return git("rev-parse", "HEAD").trimEnd() + } + + const deleteRootTree = (commitHash: string): void => { + const treeHash = git("rev-parse", `${commitHash}^{tree}`).trimEnd() + // A fresh repository stores each object loose, at objects/<2 hex>/. + rmSync(join(repoDir, ".git", "objects", treeHash.slice(0, 2), treeHash.slice(2))) + } + + mkdirSync(repoDir) + git("init", "--quiet") + commit("feat: first release", { + "package.json": toJsonFile(BASE_MANIFEST), + "package-lock.json": toJsonFile(BASE_LOCKFILE), + "src/index.ts": "export {}\n", + }) + git("tag", "v0.1.0") + + const generateNotes = (): NotesRun => { + git("tag", "v0.2.0") + const result = spawnSync("bash", [SCRIPT_PATH, "v0.2.0"], { + cwd: repoDir, + env, + encoding: "utf8", + }) + return { status: result.status, stdout: result.stdout, stderr: result.stderr } + } + + return { commit, deleteRootTree, generateNotes } +} + +/** A commit the notes always list, so a run that leaves out every commit + * ("No notable changes.") cannot pass a test that expects another commit + * to be left out. */ +const commitListedFeature = (repo: ReleaseRepo): void => { + repo.commit("feat: add search", { "src/search.ts": "export const search = 1\n" }) +} + +const LISTED_FEATURE_ONLY = "### Features\n\n- Add search\n" + +/** The base lockfile with each install path's entry replaced; null removes + * the entry. */ +const lockfileWith = (entries: Record | null>): Lockfile => { + const replacedPackages = { ...BASE_LOCKFILE.packages, ...entries } + const keptPackages = Object.entries(replacedPackages).flatMap(([installPath, entry]) => { + return entry === null ? [] : [[installPath, entry] as const] + }) + return { ...BASE_LOCKFILE, packages: Object.fromEntries(keptPackages) } +} + +describe("generate-notes.sh", () => { + describe("listing and grouping", () => { + it("groups commits under headings in type order, newest first within a heading", () => { + const repo = createReleaseRepo() + repo.commit("fix(server): handle a missing note", { "src/a.ts": "a\n" }) + repo.commit("feat: add search", { "src/b.ts": "b\n" }) + repo.commit("feat(cli): add a flag", { "src/c.ts": "c\n" }) + repo.commit("Update the readme", { "README.md": "readme\n" }) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: + "### Features\n\n- **cli:** Add a flag\n- Add search\n\n" + + "### Bug Fixes\n\n- **server:** Handle a missing note\n\n" + + "### Other Changes\n\n- Update the readme\n", + stderr: "", + }) + }) + + it("leads with breaking changes, described by the BREAKING CHANGE footer", () => { + const repo = createReleaseRepo() + commitListedFeature(repo) + repo.commit("feat!: rename the search tool\n\nBREAKING CHANGE: search is now find", { + "src/search.ts": "export const find = 1\n", + }) + + expect(repo.generateNotes().stdout).toBe( + "### ⚠ BREAKING CHANGES\n\n- search is now find\n\n" + + "### Features\n\n- Rename the search tool\n- Add search\n", + ) + }) + + it("leaves out release bump commits", () => { + const repo = createReleaseRepo() + commitListedFeature(repo) + repo.commit("release: v0.2.0", { "src/version.ts": "0.2.0\n" }) + + expect(repo.generateNotes().stdout).toBe(LISTED_FEATURE_ONLY) + }) + + it("says there are no notable changes when every commit is left out", () => { + const repo = createReleaseRepo() + repo.commit("chore(deps-dev): bump a dev tool", { "src/tooling.ts": "tool\n" }) + repo.commit("docs: update the conventions", { "AGENTS.md": "conventions\n" }) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "No notable changes.\n", + stderr: "", + }) + }) + }) + + describe("contributor-only scopes", () => { + it("leaves out a deps-dev commit", () => { + const repo = createReleaseRepo() + commitListedFeature(repo) + repo.commit("chore(deps-dev): bump a dev tool", { "src/tooling.ts": "tool\n" }) + + expect(repo.generateNotes().stdout).toBe(LISTED_FEATURE_ONLY) + }) + + it("leaves out a hooks commit even when it also changes files under src/", () => { + const repo = createReleaseRepo() + commitListedFeature(repo) + repo.commit("chore(hooks): install dependencies on worktree entry", { + ".claude/hooks/install-deps.sh": "#!/bin/bash\n", + "src/__tests__/install-deps.test.ts": "test\n", + }) + + expect(repo.generateNotes().stdout).toBe(LISTED_FEATURE_ONLY) + }) + + it("leaves out a deps-dev commit git cannot diff, without a warning", () => { + const repo = createReleaseRepo() + commitListedFeature(repo) + const commitHash = repo.commit("chore(deps-dev): bump a dev tool", { + "src/tooling.ts": "tool\n", + }) + repo.deleteRootTree(commitHash) + + expect(repo.generateNotes()).toEqual({ status: 0, stdout: LISTED_FEATURE_ONLY, stderr: "" }) + }) + }) + + describe("contributor-only paths", () => { + it.each([ + { label: "AGENTS.md", path: "AGENTS.md" }, + { label: "CHANGELOG.md", path: "CHANGELOG.md" }, + { label: "a dotfile at the repository root", path: ".gitignore" }, + { label: "a file under .claude/", path: ".claude/settings.json" }, + { label: "a file under .devin/", path: ".devin/wiki.json" }, + { label: "a file under .husky/", path: ".husky/pre-commit" }, + ])("leaves out a commit that changes only $label", ({ path }) => { + const repo = createReleaseRepo() + commitListedFeature(repo) + repo.commit("docs: update contributor notes", { [path]: "contributor\n" }) + + expect(repo.generateNotes().stdout).toBe(LISTED_FEATURE_ONLY) + }) + + it("keeps a commit that changes only files under a dot-folder such as .github/", () => { + const repo = createReleaseRepo() + repo.commit("ci: run tests on push", { ".github/workflows/ci.yml": "on: push\n" }) + + expect(repo.generateNotes().stdout).toBe("### CI / Infrastructure\n\n- Run tests on push\n") + }) + + it.each([ + { label: ".dockerignore, which decides what the image build copies", path: ".dockerignore" }, + { label: ".env.example, which DEPLOY.md has deployers copy", path: ".env.example" }, + ])("keeps a commit that changes only $label", ({ path }) => { + const repo = createReleaseRepo() + repo.commit("docs: update the deploy settings", { [path]: "setting\n" }) + + expect(repo.generateNotes().stdout).toBe( + "### Documentation\n\n- Update the deploy settings\n", + ) + }) + + it("keeps a commit that changes a contributor-only file and a shipped file", () => { + const repo = createReleaseRepo() + repo.commit("fix: handle empty notes", { + "AGENTS.md": "conventions\n", + "src/notes.ts": "notes\n", + }) + + expect(repo.generateNotes().stdout).toBe("### Bug Fixes\n\n- Handle empty notes\n") + }) + + it("keeps an empty commit", () => { + const repo = createReleaseRepo() + repo.commit("chore: trigger a release", {}) + + expect(repo.generateNotes().stdout).toBe("### Maintenance\n\n- Trigger a release\n") + }) + + it("keeps a commit git cannot diff, and warns", () => { + const repo = createReleaseRepo() + // Changes only AGENTS.md, so a successful diff would leave it out. + const commitHash = repo.commit("docs: update the conventions", { + "AGENTS.md": "conventions\n", + }) + repo.deleteRootTree(commitHash) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "### Documentation\n\n- Update the conventions\n", + stderr: expect.stringMatching( + new RegExp( + `^generate-notes: git diff-tree ${commitHash} failed \\(exit 128\\): ` + + "fatal: .+ — listing the commit\\n$", + ), + ), + }) + }) + }) + + describe("dev-dependency-only commits", () => { + it("leaves out a dev dependency bump that adds a dev-only package to the lockfile", () => { + const repo = createReleaseRepo() + commitListedFeature(repo) + repo.commit("chore(deps): bump vitest", { + "package.json": toJsonFile({ ...BASE_MANIFEST, devDependencies: { vitest: "^2.0.0" } }), + "package-lock.json": toJsonFile( + lockfileWith({ + "": { ...BASE_LOCKFILE.packages[""], devDependencies: { vitest: "^2.0.0" } }, + "node_modules/vitest": { version: "2.0.0", dev: true }, + "node_modules/tinypool": { version: "1.0.0", dev: true }, + }), + ), + }) + + expect(repo.generateNotes().stdout).toBe(LISTED_FEATURE_ONLY) + }) + + it("leaves out a commit that changes only devDependencies in package.json", () => { + const repo = createReleaseRepo() + commitListedFeature(repo) + repo.commit("chore(deps): widen the vitest range", { + "package.json": toJsonFile({ ...BASE_MANIFEST, devDependencies: { vitest: ">=1.0.0" } }), + }) + + expect(repo.generateNotes().stdout).toBe(LISTED_FEATURE_ONLY) + }) + + it("leaves out a lockfile-only change that moves only dev-only packages", () => { + const repo = createReleaseRepo() + commitListedFeature(repo) + repo.commit("chore(deps): refresh the lockfile", { + "package-lock.json": toJsonFile( + lockfileWith({ "node_modules/vitest": { version: "1.0.1", dev: true } }), + ), + }) + + expect(repo.generateNotes().stdout).toBe(LISTED_FEATURE_ONLY) + }) + + it("leaves out an override that moves only dev-only packages", () => { + const repo = createReleaseRepo() + commitListedFeature(repo) + repo.commit("fix(deps): override esbuild for a security fix", { + "package.json": toJsonFile({ ...BASE_MANIFEST, overrides: { esbuild: "0.25.0" } }), + "package-lock.json": toJsonFile( + lockfileWith({ "node_modules/esbuild": { version: "0.25.0", dev: true } }), + ), + }) + + expect(repo.generateNotes().stdout).toBe(LISTED_FEATURE_ONLY) + }) + + it("leaves out an override the lockfile also records in the project's own entry", () => { + const repo = createReleaseRepo() + commitListedFeature(repo) + repo.commit("fix(deps): override esbuild for a security fix", { + "package.json": toJsonFile({ ...BASE_MANIFEST, overrides: { esbuild: "0.25.0" } }), + "package-lock.json": toJsonFile( + lockfileWith({ + "": { ...BASE_LOCKFILE.packages[""], overrides: { esbuild: "0.25.0" } }, + "node_modules/esbuild": { version: "0.25.0", dev: true }, + }), + ), + }) + + expect(repo.generateNotes().stdout).toBe(LISTED_FEATURE_ONLY) + }) + + it("keeps a commit that changes the dependencies the app installs", () => { + const repo = createReleaseRepo() + repo.commit("chore(deps): bump zod", { + "package.json": toJsonFile({ ...BASE_MANIFEST, dependencies: { zod: "^2.0.0" } }), + "package-lock.json": toJsonFile( + lockfileWith({ + "": { ...BASE_LOCKFILE.packages[""], dependencies: { zod: "^2.0.0" } }, + "node_modules/zod": { version: "2.0.0" }, + }), + ), + }) + + expect(repo.generateNotes().stdout).toBe("### Maintenance\n\n- **deps:** Bump zod\n") + }) + + it("keeps a commit that changes a package.json field other than devDependencies or overrides", () => { + const repo = createReleaseRepo() + repo.commit("chore: add a start script", { + "package.json": toJsonFile({ ...BASE_MANIFEST, scripts: { start: "node ." } }), + }) + + expect(repo.generateNotes().stdout).toBe("### Maintenance\n\n- Add a start script\n") + }) + + it("keeps a dev dependency bump that also moves a package the app installs", () => { + const repo = createReleaseRepo() + repo.commit("chore(deps): bump vitest", { + "package.json": toJsonFile({ ...BASE_MANIFEST, devDependencies: { vitest: "^2.0.0" } }), + "package-lock.json": toJsonFile( + lockfileWith({ + "": { ...BASE_LOCKFILE.packages[""], devDependencies: { vitest: "^2.0.0" } }, + "node_modules/vitest": { version: "2.0.0", dev: true }, + "node_modules/zod": { version: "1.0.1" }, + }), + ), + }) + + expect(repo.generateNotes().stdout).toBe("### Maintenance\n\n- **deps:** Bump vitest\n") + }) + + it("keeps a lockfile change that removes a package the app installs", () => { + const repo = createReleaseRepo() + repo.commit("chore(deps): prune the lockfile", { + "package-lock.json": toJsonFile(lockfileWith({ "node_modules/zod": null })), + }) + + expect(repo.generateNotes().stdout).toBe( + "### Maintenance\n\n- **deps:** Prune the lockfile\n", + ) + }) + + it("keeps a lockfile change to the project's own dependencies", () => { + const repo = createReleaseRepo() + repo.commit("chore(deps): sync the lockfile", { + "package-lock.json": toJsonFile( + lockfileWith({ + "": { ...BASE_LOCKFILE.packages[""], dependencies: { zod: "^1.1.0" } }, + }), + ), + }) + + expect(repo.generateNotes().stdout).toBe("### Maintenance\n\n- **deps:** Sync the lockfile\n") + }) + + it("keeps a commit that deletes package-lock.json", () => { + const repo = createReleaseRepo() + repo.commit("chore: drop the lockfile", { "package-lock.json": null }) + + expect(repo.generateNotes().stdout).toBe("### Maintenance\n\n- Drop the lockfile\n") + }) + + it("keeps a commit whose package-lock.json is not valid JSON, and warns", () => { + const repo = createReleaseRepo() + const commitHash = repo.commit("chore(deps): bump vitest", { + "package.json": toJsonFile({ ...BASE_MANIFEST, devDependencies: { vitest: "^2.0.0" } }), + "package-lock.json": "not json\n", + }) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "### Maintenance\n\n- **deps:** Bump vitest\n", + stderr: + `generate-notes: package-lock.json at ${commitHash} is not valid JSON ` + + "(Expecting value: line 1 column 1 (char 0)) — listing the commit\n", + }) + }) + + it("keeps a commit whose package.json is not a JSON object, and warns", () => { + const repo = createReleaseRepo() + const commitHash = repo.commit("chore: empty the manifest", { "package.json": "[]\n" }) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "### Maintenance\n\n- Empty the manifest\n", + stderr: + `generate-notes: package.json at ${commitHash} is not a JSON object ` + + "— listing the commit\n", + }) + }) + + it("keeps a lockfile change when the lockfile has no packages map", () => { + const repo = createReleaseRepo() + // npm 6's lockfile format lists packages under `dependencies` instead. + const npm6LockfileWithZod = (zodVersion: string): string => { + return toJsonFile({ + name: "fixture", + lockfileVersion: 1, + dependencies: { + zod: { version: zodVersion }, + vitest: { version: "1.0.0", dev: true }, + }, + }) + } + repo.commit("chore: switch to the npm 6 lockfile", { + "package-lock.json": npm6LockfileWithZod("1.0.0"), + }) + repo.commit("fix(deps): bump zod", { "package-lock.json": npm6LockfileWithZod("1.0.1") }) + + expect(repo.generateNotes().stdout).toBe( + "### Bug Fixes\n\n- **deps:** Bump zod\n\n" + + "### Maintenance\n\n- Switch to the npm 6 lockfile\n", + ) + }) + + it.each([ + { label: "an installed package's entry", installPath: "node_modules/vitest", entry: "1.0.1" }, + { label: "the project's own entry", installPath: "", entry: "fixture" }, + { + label: "a new install path's entry, set to null", + installPath: "node_modules/left-pad", + entry: null, + }, + ])("keeps a lockfile change where $label is not an object", ({ installPath, entry }) => { + const repo = createReleaseRepo() + repo.commit("chore(deps): bump vitest", { + "package-lock.json": toJsonFile({ + ...BASE_LOCKFILE, + packages: { ...BASE_LOCKFILE.packages, [installPath]: entry }, + }), + }) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "### Maintenance\n\n- **deps:** Bump vitest\n", + stderr: "", + }) + }) + }) +})