From b5de1ebb751efd4a6aa563ceeae74e771a1f37fe Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:34:06 -0400 Subject: [PATCH 01/21] ci(release): leave contributor-only commits out of server release notes generate-notes.sh now skips dev-dependency bumps and Claude Code hook changes (scopes deps-dev and hooks), and commits that change only AGENTS.md, CHANGELOG.md, or files under .claude/ or .husky/. CLI release notes are unchanged: they still list every commit that touches cli/, dependency bumps included. CONTRIBUTING.md now says what each release's notes list. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 41 ++++++++++++++++++++++++++----- CONTRIBUTING.md | 9 +++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index 64f7e74d1..5725c8620 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -3,6 +3,12 @@ # 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, or files under .claude/ +# or .husky/ +# # 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 @@ -25,13 +31,13 @@ else 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 @@ -108,13 +114,36 @@ def pr_info(pr_number): _pr_cache[pr_number] = info return info +# Commits with these scopes, or that change only these files, change nothing a +# user installs. Hook commits also touch their tests, so the scope catches them. +CONTRIBUTOR_ONLY_SCOPES = {"deps-dev", "hooks"} + +def is_contributor_only_path(path): + return path in ("AGENTS.md", "CHANGELOG.md") or path.startswith((".claude/", ".husky/")) + +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, check=True, + ) + return [path for path in result.stdout.split("\x00") if path] + for rec in records: - subject, _, body = rec.partition("\x1f") + commit_hash, _, rest = rec.partition("\x1f") + subject, _, body = rest.partition("\x1f") subject = subject.strip() if not subject or release_pat.match(subject): continue match = pat.match(subject) + has_contributor_only_scope = bool(match) and match.group(2) in CONTRIBUTOR_ONLY_SCOPES + paths = changed_paths(commit_hash.strip()) + changes_only_contributor_files = bool(paths) and all( + is_contributor_only_path(path) for path in paths + ) + if has_contributor_only_scope or changes_only_contributor_files: + continue + pr_num = pr_pat.search(subject) pr = pr_info(pr_num.group(1)) if pr_num else None diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ec2b8ed0..766b39529 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -237,6 +237,15 @@ version bumps included, land via PR. The CLI releases separately: Actions tab → "Release CLI" (see [The `cli/` Package](#the-cli-package)). +What each release's notes list: + +- **Server releases:** every commit since the last release, except release + bumps, dev-dependency bumps and Claude Code hook changes (commit scopes + `deps-dev` and `hooks`), and commits that change only `AGENTS.md`, + `CHANGELOG.md`, or files under `.claude/` or `.husky/`. +- **CLI releases:** every commit that touches `cli/`, dependency bumps + included. + See the [DEPLOY.md CI/CD section](./DEPLOY.md#cicd) for details on each workflow. ## Railway template From 602c78af87b80c99971771250048cddaae6190bf Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:39:27 -0400 Subject: [PATCH 02/21] fix(review): leave dev-only dependency updates out of server notes by lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security updates and overrides of transitive dev packages arrive with the `deps` scope, so the `deps-dev` scope alone kept them in the notes (the markdown-it/markdownlint-cli2 bump is the next release's case). A commit that changes only the root package.json and package-lock.json, and moves only packages the lockfile marks `"dev": true`, is now left out too. Ship-Check: pr-review · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 43 +++++++++++++++++++++++++++++++ CONTRIBUTING.md | 12 ++++++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index 5725c8620..1dab5cc4e 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -8,6 +8,8 @@ # - dev-dependency bumps and Claude Code hook changes (scopes `deps-dev`, `hooks`) # - commits that change only AGENTS.md, CHANGELOG.md, or files under .claude/ # or .husky/ +# - commits that change only the root package.json and package-lock.json and +# move only dev dependencies # # 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 @@ -128,6 +130,45 @@ def changed_paths(commit_hash): ) return [path for path in result.stdout.split("\x00") if path] +ROOT_MANIFESTS = {"package.json", "package-lock.json"} + +def read_json_at(revision, path): + result = subprocess.run(["git", "show", f"{revision}:{path}"], capture_output=True, text=True) + return json.loads(result.stdout) if result.returncode == 0 else None + +def changed_entries(old_mapping, new_mapping): + keys = old_mapping.keys() | new_mapping.keys() + return [key for key in keys if old_mapping.get(key) != new_mapping.get(key)] + +# 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`. Anything unreadable counts as a user +# change. +def changes_only_dev_dependencies(commit_hash, paths): + if not paths or not set(paths) <= ROOT_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_entries(old_manifest, new_manifest)) <= {"devDependencies", "overrides"}: + return False + old_packages, new_packages = old_lock.get("packages", {}), new_lock.get("packages", {}) + for package_path in changed_entries(old_packages, new_packages): + # "" is the project itself; its entry mirrors package.json's dependency lists. + if package_path == "": + root_fields = changed_entries(old_packages.get("", {}), new_packages.get("", {})) + if not set(root_fields) <= {"devDependencies"}: + return False + continue + versions = [packages[package_path] for packages in (old_packages, new_packages) if package_path in packages] + if not all(version.get("dev") is True for version in versions): + return False + return True + for rec in records: commit_hash, _, rest = rec.partition("\x1f") subject, _, body = rest.partition("\x1f") @@ -143,6 +184,8 @@ for rec in records: ) if has_contributor_only_scope or changes_only_contributor_files: continue + if changes_only_dev_dependencies(commit_hash.strip(), paths): + continue pr_num = pr_pat.search(subject) pr = pr_info(pr_num.group(1)) if pr_num else None diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 766b39529..ce96860bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -239,10 +239,14 @@ The CLI releases separately: Actions tab → "Release CLI" (see What each release's notes list: -- **Server releases:** every commit since the last release, except release - bumps, dev-dependency bumps and Claude Code hook changes (commit scopes - `deps-dev` and `hooks`), and commits that change only `AGENTS.md`, - `CHANGELOG.md`, or files under `.claude/` or `.husky/`. +- **Server releases:** every commit since the last release, except: + - release bumps + - dev-dependency bumps: commits with the `deps-dev` scope, and commits that + change only the root `package.json` and `package-lock.json` and update only + packages the lockfile marks `"dev": true` + - Claude Code hook changes (commit scope `hooks`) + - commits that change only `AGENTS.md`, `CHANGELOG.md`, or files under + `.claude/` or `.husky/` - **CLI releases:** every commit that touches `cli/`, dependency bumps included. From 4ac9bffbb6782979e999cb16de540916f4cd5ec7 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:41:11 -0400 Subject: [PATCH 03/21] ci(release): leave root dotfile and .devin/-only commits out of server notes A commit that changes only dotfiles at the repository root (.gitignore, .trivyignore and the like) or files under .devin/ now stays out of server release notes, beside the existing .claude/ and .husky/ rule. .github/ stays listed, so CI changes still show. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 17 ++++++++++++----- CONTRIBUTING.md | 5 +++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index 1dab5cc4e..2894c58eb 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -6,8 +6,8 @@ # 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, or files under .claude/ -# or .husky/ +# - commits that change only AGENTS.md, CHANGELOG.md, dotfiles at the repository +# root (.gitignore, .trivyignore), or files under .claude/, .devin/ or .husky/ # - commits that change only the root package.json and package-lock.json and # move only dev dependencies # @@ -116,12 +116,19 @@ def pr_info(pr_number): _pr_cache[pr_number] = info return info -# Commits with these scopes, or that change only these files, change nothing a -# user installs. Hook commits also touch their tests, so the scope catches them. +# Commits with these scopes change nothing a user installs. Hook commits also +# change their tests, so a path rule alone would miss them. CONTRIBUTOR_ONLY_SCOPES = {"deps-dev", "hooks"} +# A commit that changes only these paths changes nothing a user installs. +# .github/ is not listed: CI changes stay in the notes. def is_contributor_only_path(path): - return path in ("AGENTS.md", "CHANGELOG.md") or path.startswith((".claude/", ".husky/")) + is_root_dotfile = path.startswith(".") and "/" not in path + return ( + is_root_dotfile + or path in ("AGENTS.md", "CHANGELOG.md") + or path.startswith((".claude/", ".devin/", ".husky/")) + ) def changed_paths(commit_hash): result = subprocess.run( diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce96860bb..d534fbd89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -245,8 +245,9 @@ What each release's notes list: change only the root `package.json` and `package-lock.json` and update only packages the lockfile marks `"dev": true` - Claude Code hook changes (commit scope `hooks`) - - commits that change only `AGENTS.md`, `CHANGELOG.md`, or files under - `.claude/` or `.husky/` + - commits that change only `AGENTS.md`, `CHANGELOG.md`, dotfiles at the + repository root (`.gitignore`, `.trivyignore`), or files under `.claude/`, + `.devin/` or `.husky/` - **CLI releases:** every commit that touches `cli/`, dependency bumps included. From 1da8153627d86df169f6724e94924187af2ea53f Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:49:44 -0400 Subject: [PATCH 04/21] style: clarify release-notes filters and their docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generate-notes.sh: named regex groups, explicit names for single-letter and shorthand variables, one hash strip with its reason, a named manifests-only boolean, and dead fallbacks removed - comments: state the package.json fields the dev-dependency rule allows, the first-release range, the gh repo fallback, lockfile key shape, and the empty-commit guard; narrow "anything unreadable" to a missing manifest - CONTRIBUTING.md: name each notes generator, state the package.json fields the dev-dependency rule allows, and say a tag push deploys and creates the GitHub Release Generated notes are byte-identical before and after for every commit from v0.1.1 to HEAD. Ship-Check: code-quality · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 127 +++++++++++++++++------------- CONTRIBUTING.md | 13 +-- 2 files changed, 81 insertions(+), 59 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index 2894c58eb..f87186ab1 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -8,8 +8,8 @@ # - 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), or files under .claude/, .devin/ or .husky/ -# - commits that change only the root package.json and package-lock.json and -# move only dev dependencies +# - 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 @@ -30,6 +30,7 @@ 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 @@ -44,11 +45,11 @@ 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"), @@ -58,15 +59,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. @@ -78,6 +81,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 = {} @@ -117,12 +122,13 @@ def pr_info(pr_number): return info # Commits with these scopes change nothing a user installs. Hook commits also -# change their tests, so a path rule alone would miss them. +# change their tests in src/__tests__/, so a path rule alone would miss them. CONTRIBUTOR_ONLY_SCOPES = {"deps-dev", "hooks"} # A commit that changes only these paths changes nothing a user installs. -# .github/ is not listed: CI changes stay in the notes. 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 return ( is_root_dotfile @@ -143,17 +149,19 @@ def read_json_at(revision, path): result = subprocess.run(["git", "show", f"{revision}:{path}"], capture_output=True, text=True) return json.loads(result.stdout) if result.returncode == 0 else None -def changed_entries(old_mapping, new_mapping): +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) != new_mapping.get(key)] # 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`. Anything unreadable counts as a user -# change. +# devDependencies reach with `"dev": true`. A manifest missing at either revision +# counts as a user change. def changes_only_dev_dependencies(commit_hash, paths): - if not paths or not set(paths) <= ROOT_MANIFESTS: + 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") @@ -161,64 +169,77 @@ def changes_only_dev_dependencies(commit_hash, paths): 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_entries(old_manifest, new_manifest)) <= {"devDependencies", "overrides"}: + if not set(changed_keys(old_manifest, new_manifest)) <= {"devDependencies", "overrides"}: return False - old_packages, new_packages = old_lock.get("packages", {}), new_lock.get("packages", {}) - for package_path in changed_entries(old_packages, new_packages): - # "" is the project itself; its entry mirrors package.json's dependency lists. - if package_path == "": - root_fields = changed_entries(old_packages.get("", {}), new_packages.get("", {})) + + # 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 but not `overrides`. + old_packages = old_lock.get("packages", {}) + new_packages = new_lock.get("packages", {}) + for install_path in changed_keys(old_packages, new_packages): + if install_path == "": + root_fields = changed_keys(old_packages.get("", {}), new_packages.get("", {})) if not set(root_fields) <= {"devDependencies"}: return False continue - versions = [packages[package_path] for packages in (old_packages, new_packages) if package_path in packages] - if not all(version.get("dev") is True for version in versions): + lock_entries = [ + packages[install_path] + for packages in (old_packages, new_packages) + if install_path in packages + ] + if not all(lock_entry.get("dev") is True for lock_entry in lock_entries): return False return True -for rec in records: - commit_hash, _, rest = rec.partition("\x1f") +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) - has_contributor_only_scope = bool(match) and match.group(2) in CONTRIBUTOR_ONLY_SCOPES - paths = changed_paths(commit_hash.strip()) + match = subject_pat.match(subject) + has_contributor_only_scope = bool(match) and match["scope"] in CONTRIBUTOR_ONLY_SCOPES + 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 has_contributor_only_scope or changes_only_contributor_files: continue - if changes_only_dev_dependencies(commit_hash.strip(), paths): + if changes_only_dev_dependencies(commit_hash, paths): continue - pr_num = pr_pat.search(subject) - pr = pr_info(pr_num.group(1)) if pr_num else None + 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 @@ -228,20 +249,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/CONTRIBUTING.md b/CONTRIBUTING.md index d534fbd89..87621d3e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -229,7 +229,7 @@ Releases are cut by the maintainer. Two paths: - **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` + `git tag v && git push --tags`. Deploys, creates GitHub Release. Direct commits to `main` are blocked by a branch ruleset — all changes, version bumps included, land via PR. @@ -239,17 +239,18 @@ The CLI releases separately: Actions tab → "Release CLI" (see What each release's notes list: -- **Server releases:** every commit since the last release, except: +- **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 the root `package.json` and `package-lock.json` and update only - packages the lockfile marks `"dev": true` + 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`), or files under `.claude/`, `.devin/` or `.husky/` -- **CLI releases:** every commit that touches `cli/`, dependency bumps - included. +- **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. From 4447aaf974efa3743d243f57ded25f9487e844c1 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:51:19 -0400 Subject: [PATCH 05/21] ci(release): list a commit whose manifest is not valid JSON instead of failing the release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_json_at now warns and returns None for a package.json or package-lock.json that does not parse, so the dev-dependency check keeps that commit in the notes instead of stopping the release job. Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index f87186ab1..0bcb68730 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -147,7 +147,13 @@ ROOT_MANIFESTS = {"package.json", "package-lock.json"} def read_json_at(revision, path): result = subprocess.run(["git", "show", f"{revision}:{path}"], capture_output=True, text=True) - return json.loads(result.stdout) if result.returncode == 0 else None + if result.returncode != 0: + return None + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + warn(f"{path} at {revision} is not valid JSON ({error}) — listing the commit") + return None def changed_keys(old_mapping, new_mapping): keys = old_mapping.keys() | new_mapping.keys() @@ -155,8 +161,8 @@ def changed_keys(old_mapping, new_mapping): # 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 at either revision -# counts as a user change. +# 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: From 743d1431aad992df6a6408d16325a67a3650abea Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:59:58 -0400 Subject: [PATCH 06/21] test(release): cover which commits the server release notes leave out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs generate-notes.sh for real against a temporary git repository per test. Covers the contributor-only scopes and paths, the dev-dependency check over package.json and package-lock.json, the warning for a manifest that is not valid JSON, and the heading layout. Ship-Check: test-audit · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/__tests__/generate-notes.test.ts | 373 +++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 src/__tests__/generate-notes.test.ts diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts new file mode 100644 index 000000000..0de7d4db0 --- /dev/null +++ b/src/__tests__/generate-notes.test.ts @@ -0,0 +1,373 @@ +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 + /** 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() + } + + 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, 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) + }) + }) + + 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) + }) + }) + + 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("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") + }) + }) + + 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 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("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", + }) + }) + }) +}) From de16e3e33607d49badb4ef5c622a4457f45044a0 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:00:50 -0400 Subject: [PATCH 07/21] docs(agents): list the release-notes script test with the other shell-script tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2074301fd..248e79bfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -946,17 +946,20 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal): **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 + the Claude Code hook sits in `.claude/hooks/`, and the release scripts + 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-notes + script's tests in `src/__tests__/` (`install-deps.test.ts`, + `generate-notes.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, From 4e3d58f8fc49380509521e764db8be2413627bb3 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:01:07 -0400 Subject: [PATCH 08/21] docs(agents): fix the run-on sentence in the shell-script test exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 248e79bfd..aae6ed650 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -945,8 +945,8 @@ 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/`, and the release scripts + 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 From fc6c3b16bffa070dc67433008464c16e4b11af79 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:13:50 -0400 Subject: [PATCH 09/21] ci(release): list commits whose manifests the dev-dependency check cannot read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lockfile without a packages map (npm 6's lockfileVersion 1) compared as two empty maps, so a lockfile-only commit that moved production packages was left out of the server notes. Such a commit is now listed. A package.json or package-lock.json that is valid JSON but not an object crashed the script; it now warns and lists the commit, like invalid JSON. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 15 ++++++++--- src/__tests__/generate-notes.test.ts | 37 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index 0bcb68730..aa7f81bc5 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -150,10 +150,14 @@ def read_json_at(revision, path): if result.returncode != 0: return None try: - return json.loads(result.stdout) + 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 def changed_keys(old_mapping, new_mapping): keys = old_mapping.keys() | new_mapping.keys() @@ -181,8 +185,13 @@ def changes_only_dev_dependencies(commit_hash, paths): # 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 but not `overrides`. - old_packages = old_lock.get("packages", {}) - new_packages = new_lock.get("packages", {}) + 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): if install_path == "": root_fields = changed_keys(old_packages.get("", {}), new_packages.get("", {})) diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts index 0de7d4db0..a17a3b4fd 100644 --- a/src/__tests__/generate-notes.test.ts +++ b/src/__tests__/generate-notes.test.ts @@ -369,5 +369,42 @@ describe("generate-notes.sh", () => { "(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", + ) + }) }) }) From 2adf59096c7d955272e2ed29dba536f6be689278 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:14:50 -0400 Subject: [PATCH 10/21] docs: say the release workflows push their own commits to main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING.md said every change lands via PR, but the release workflows push their version-bump and CHANGELOG commits directly. It now says so, matching DEPLOY.md. Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87621d3e6..26d49ff3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -232,7 +232,9 @@ Releases are cut by the maintainer. Two paths: `git tag v && git push --tags`. Deploys, creates GitHub Release. Direct commits to `main` are blocked by a branch ruleset — all changes, -version bumps included, land via PR. +version bumps included, land via PR. Release automation is the only actor +that pushes to `main` directly: the release workflows' version-bump and +CHANGELOG commits. The CLI releases separately: Actions tab → "Release CLI" (see [The `cli/` Package](#the-cli-package)). From 2c8ecc127c908481766810042af65cf586188be9 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:18:05 -0400 Subject: [PATCH 11/21] ci(release): list a commit git cannot diff, and allow overrides in the lockfile's own entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changed_paths now warns and returns no paths when git diff-tree fails, so the commit stays listed instead of the notes step failing after deploy, as the manifest reads already do. The lockfile's project entry may change the same fields as package.json (devDependencies and overrides), so the rule no longer depends on whether npm copies overrides into it. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 16 +++++++++++----- src/__tests__/generate-notes.test.ts | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index aa7f81bc5..fcba25b76 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -136,14 +136,20 @@ def is_contributor_only_path(path): 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, check=True, + capture_output=True, text=True, ) + 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) @@ -179,12 +185,12 @@ def changes_only_dev_dependencies(commit_hash, paths): 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)) <= {"devDependencies", "overrides"}: + 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 but not `overrides`. + # path (`node_modules/foo`). Its "" entry is the project itself, which + # copies fields from package.json. old_packages = old_lock.get("packages") new_packages = new_lock.get("packages") has_package_maps = isinstance(old_packages, dict) and isinstance(new_packages, dict) @@ -195,7 +201,7 @@ def changes_only_dev_dependencies(commit_hash, paths): for install_path in changed_keys(old_packages, new_packages): if install_path == "": root_fields = changed_keys(old_packages.get("", {}), new_packages.get("", {})) - if not set(root_fields) <= {"devDependencies"}: + if not set(root_fields) <= DEV_ONLY_MANIFEST_FIELDS: return False continue lock_entries = [ diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts index a17a3b4fd..57745cae8 100644 --- a/src/__tests__/generate-notes.test.ts +++ b/src/__tests__/generate-notes.test.ts @@ -283,6 +283,22 @@ describe("generate-notes.sh", () => { 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", { From 62f56da1a2df577fabc580bf3d2eccc7f461d0a2 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:26:37 -0400 Subject: [PATCH 12/21] ci(release): skip a deps-dev or hooks commit before looking up its paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A commit git cannot diff got a warning that it was listed, then the scope rule left it out. The scope rule now runs first, so the warning only fires for commits it keeps. Tests cover both outcomes of a failed diff. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 6 +++- src/__tests__/generate-notes.test.ts | 42 +++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index fcba25b76..afa6d412f 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -225,12 +225,16 @@ for record in records: 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 has_contributor_only_scope or changes_only_contributor_files: + if changes_only_contributor_files: continue if changes_only_dev_dependencies(commit_hash, paths): continue diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts index 57745cae8..7f5a627d2 100644 --- a/src/__tests__/generate-notes.test.ts +++ b/src/__tests__/generate-notes.test.ts @@ -65,6 +65,9 @@ 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 } @@ -108,6 +111,12 @@ const createReleaseRepo = (): ReleaseRepo => { 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", { @@ -127,7 +136,7 @@ const createReleaseRepo = (): ReleaseRepo => { return { status: result.status, stdout: result.stdout, stderr: result.stderr } } - return { commit, generateNotes } + return { commit, deleteRootTree, generateNotes } } /** A commit the notes always list, so a run that leaves out every commit @@ -209,6 +218,17 @@ describe("generate-notes.sh", () => { 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", () => { @@ -250,6 +270,26 @@ describe("generate-notes.sh", () => { 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", () => { From f063b72bce39ccf2fb460c37377ae2b95a078f2f Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:26:45 -0400 Subject: [PATCH 13/21] docs: say only a tag push's version bump lands via PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual Release and Release CLI push their version-bump commits to main directly, as the next sentence in CONTRIBUTING.md and DEPLOY.md says. Ship-Check: bug-check · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 6 +++--- DEPLOY.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26d49ff3f..91d16c1e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -231,9 +231,9 @@ Releases are cut by the maintainer. Two paths: - **Tag push:** merge a version-bump PR into `main`, then `git tag v && git push --tags`. Deploys, creates GitHub Release. -Direct commits to `main` are blocked by a branch ruleset — all changes, -version bumps included, land via PR. Release automation is the only actor -that pushes to `main` directly: the release workflows' version-bump and +Direct commits to `main` are blocked by a branch ruleset — all changes, a tag +push's version bump included, land via PR. Release automation is the only +actor that pushes to `main` directly: the release workflows' version-bump and CHANGELOG commits. The CLI releases separately: Actions tab → "Release CLI" (see 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 From 2548d2085dfa30daa805d317977c1b794894755b Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:29:44 -0400 Subject: [PATCH 14/21] ci(release): keep .dockerignore-only commits in server release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root-dotfile rule now skips .dockerignore, which decides what the image build copies, so a change to it alone stays listed. git output is decoded with replacement characters, so a path or manifest that isn't UTF-8 no longer stops the notes step. The lockfile comment again says npm leaves overrides out of the project's own entry. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 15 +++++++++------ CONTRIBUTING.md | 3 ++- src/__tests__/generate-notes.test.ts | 11 +++++++++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index afa6d412f..d838d6e7e 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -7,7 +7,8 @@ # - 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), or files under .claude/, .devin/ or .husky/ +# root (.gitignore, .trivyignore; not .dockerignore), 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` # @@ -128,8 +129,8 @@ CONTRIBUTOR_ONLY_SCOPES = {"deps-dev", "hooks"} # 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 + # in the notes. .dockerignore decides what the image build copies. + is_root_dotfile = path.startswith(".") and "/" not in path and path != ".dockerignore" return ( is_root_dotfile or path in ("AGENTS.md", "CHANGELOG.md") @@ -140,7 +141,7 @@ def is_contributor_only_path(path): 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, + capture_output=True, text=True, errors="replace", ) if result.returncode != 0: warn(f"git diff-tree {commit_hash} failed (exit {result.returncode}): " @@ -152,7 +153,9 @@ 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) + result = subprocess.run( + ["git", "show", f"{revision}:{path}"], capture_output=True, text=True, errors="replace" + ) if result.returncode != 0: return None try: @@ -190,7 +193,7 @@ def changes_only_dev_dependencies(commit_hash, paths): # The lockfile's `packages` map keys each installed package by its install # path (`node_modules/foo`). Its "" entry is the project itself, which - # copies fields from package.json. + # 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) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 91d16c1e3..5cbab6f61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -249,7 +249,8 @@ What each release's notes list: 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`), or files under `.claude/`, + repository root (`.gitignore`, `.trivyignore`; not `.dockerignore`, which + decides what the image build copies), 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). diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts index 7f5a627d2..31c99cc1e 100644 --- a/src/__tests__/generate-notes.test.ts +++ b/src/__tests__/generate-notes.test.ts @@ -254,6 +254,17 @@ describe("generate-notes.sh", () => { expect(repo.generateNotes().stdout).toBe("### CI / Infrastructure\n\n- Run tests on push\n") }) + it("keeps a commit that changes only .dockerignore, which decides what the image build copies", () => { + const repo = createReleaseRepo() + repo.commit("build: copy the rootfs scripts into the image", { + ".dockerignore": "node_modules\n", + }) + + expect(repo.generateNotes().stdout).toBe( + "### Other Changes\n\n- Copy the rootfs scripts into the image\n", + ) + }) + it("keeps a commit that changes a contributor-only file and a shipped file", () => { const repo = createReleaseRepo() repo.commit("fix: handle empty notes", { From ce6a003ed52fd4fd82527b65aad61933c721d2db Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:38:10 -0400 Subject: [PATCH 15/21] test(release): cover package.json-only and lockfile-only dev-dependency commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/__tests__/generate-notes.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts index 31c99cc1e..d89602ce5 100644 --- a/src/__tests__/generate-notes.test.ts +++ b/src/__tests__/generate-notes.test.ts @@ -321,6 +321,28 @@ describe("generate-notes.sh", () => { 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) From 277f0b0d6fb6e2e92d05dac0f81de39aa35c421a Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:50:14 -0400 Subject: [PATCH 16/21] ci(release): list .env.example-only commits, and a lockfile entry that isn't an object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEPLOY.md has deployers copy the root .env.example, so a commit changing only it stays in the notes beside .dockerignore. A package-lock.json entry that isn't an object now keeps the commit listed instead of stopping the notes step, like every other unreadable manifest. Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 25 ++++++++++++++++--------- CONTRIBUTING.md | 3 ++- src/__tests__/generate-notes.test.ts | 28 +++++++++++++++++++++++----- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index d838d6e7e..ba5b81617 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -7,8 +7,8 @@ # - 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 files under .claude/, -# .devin/ or .husky/ +# 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` # @@ -126,11 +126,15 @@ def pr_info(pr_number): # 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. .dockerignore decides what the image build copies. - is_root_dotfile = path.startswith(".") and "/" not in path and path != ".dockerignore" + # 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") @@ -202,16 +206,19 @@ def changes_only_dev_dependencies(commit_hash, paths): if not has_package_maps: return False for install_path in changed_keys(old_packages, new_packages): - 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 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cbab6f61..984acb96a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -250,7 +250,8 @@ What each release's notes list: - 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 files under `.claude/`, + 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). diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts index d89602ce5..2ce458996 100644 --- a/src/__tests__/generate-notes.test.ts +++ b/src/__tests__/generate-notes.test.ts @@ -254,14 +254,15 @@ describe("generate-notes.sh", () => { expect(repo.generateNotes().stdout).toBe("### CI / Infrastructure\n\n- Run tests on push\n") }) - it("keeps a commit that changes only .dockerignore, which decides what the image build copies", () => { + 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("build: copy the rootfs scripts into the image", { - ".dockerignore": "node_modules\n", - }) + repo.commit("docs: update the deploy settings", { [path]: "setting\n" }) expect(repo.generateNotes().stdout).toBe( - "### Other Changes\n\n- Copy the rootfs scripts into the image\n", + "### Documentation\n\n- Update the deploy settings\n", ) }) @@ -495,5 +496,22 @@ describe("generate-notes.sh", () => { "### Maintenance\n\n- Switch to the npm 6 lockfile\n", ) }) + + it("keeps a lockfile change whose package entry is not an object", () => { + const repo = createReleaseRepo() + repo.commit("chore(deps): bump vitest", { + "package-lock.json": toJsonFile({ + ...BASE_LOCKFILE, + packages: { ...BASE_LOCKFILE.packages, "node_modules/vitest": "1.0.1" }, + }), + }) + + const notesRun = repo.generateNotes() + + expect({ status: notesRun.status, stdout: notesRun.stdout }).toEqual({ + status: 0, + stdout: "### Maintenance\n\n- **deps:** Bump vitest\n", + }) + }) }) }) From 739c0a504ed7a6c311d7e0dbf124753bc51a3a2e Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:03:15 -0400 Subject: [PATCH 17/21] ci(release): count a lockfile entry added as null as a change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changed_keys compared missing and null values as equal, so an install path added as null slipped past the not-an-object check and the commit was left out. A sentinel now tells a missing key from a null one. The not-an-object test also covers the project's own entry and a new null entry. Ship-Check: triage · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/generate-notes.sh | 5 ++++- src/__tests__/generate-notes.test.ts | 12 ++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/scripts/generate-notes.sh b/.github/scripts/generate-notes.sh index ba5b81617..5a2fad7ed 100755 --- a/.github/scripts/generate-notes.sh +++ b/.github/scripts/generate-notes.sh @@ -172,9 +172,12 @@ def read_json_at(revision, path): 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) != new_mapping.get(key)] + 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 diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts index 2ce458996..c9860dc86 100644 --- a/src/__tests__/generate-notes.test.ts +++ b/src/__tests__/generate-notes.test.ts @@ -497,12 +497,20 @@ describe("generate-notes.sh", () => { ) }) - it("keeps a lockfile change whose package entry is not an object", () => { + 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, "node_modules/vitest": "1.0.1" }, + packages: { ...BASE_LOCKFILE.packages, [installPath]: entry }, }), }) From b08b69aef0c992919139971b301eccd50958c8e5 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:09:17 -0400 Subject: [PATCH 18/21] docs(agents): name the changelog script's test with the other shell-script tests Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aae6ed650..3df874e02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -954,9 +954,9 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal): `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 and release-notes - script's tests in `src/__tests__/` (`install-deps.test.ts`, - `generate-notes.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 others) with stub binaries on `PATH` or, for the release-notes script, a temporary git repository, and name From 2d8a166a53628900398a2cc92a921a55ff62baa6 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:16:49 -0400 Subject: [PATCH 19/21] test(release): assert the whole run for a lockfile entry that isn't an object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/__tests__/generate-notes.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts index c9860dc86..b59325840 100644 --- a/src/__tests__/generate-notes.test.ts +++ b/src/__tests__/generate-notes.test.ts @@ -514,11 +514,10 @@ describe("generate-notes.sh", () => { }), }) - const notesRun = repo.generateNotes() - - expect({ status: notesRun.status, stdout: notesRun.stdout }).toEqual({ + expect(repo.generateNotes()).toEqual({ status: 0, stdout: "### Maintenance\n\n- **deps:** Bump vitest\n", + stderr: "", }) }) }) From b73a87817555a83fa6037487c2196c723377d013 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:29:56 -0400 Subject: [PATCH 20/21] test(release): cover a release whose every commit is left out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship-Check: pr-monitor · claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- src/__tests__/generate-notes.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/__tests__/generate-notes.test.ts b/src/__tests__/generate-notes.test.ts index b59325840..b71dd24d0 100644 --- a/src/__tests__/generate-notes.test.ts +++ b/src/__tests__/generate-notes.test.ts @@ -197,6 +197,18 @@ describe("generate-notes.sh", () => { 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", () => { From a3e0213cb712bdec693cf90d98c52323fcf6fd27 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:42:10 -0400 Subject: [PATCH 21/21] docs: write the release paths in CONTRIBUTING.md as full sentences Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 984acb96a..7d2f798be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -224,17 +224,18 @@ 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`. Deploys, creates GitHub Release. + `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, a tag -push's version bump included, land via PR. Release automation is the only -actor that pushes to `main` directly: the release workflows' version-bump and -CHANGELOG commits. +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)).