Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b5de1eb
ci(release): leave contributor-only commits out of server release notes
aliasunder Oct 21, 2026
602c78a
fix(review): leave dev-only dependency updates out of server notes by…
aliasunder Oct 21, 2026
4ac9bff
ci(release): leave root dotfile and .devin/-only commits out of serve…
aliasunder Oct 21, 2026
1da8153
style: clarify release-notes filters and their docs
aliasunder Oct 21, 2026
4447aaf
ci(release): list a commit whose manifest is not valid JSON instead o…
aliasunder Oct 21, 2026
743d143
test(release): cover which commits the server release notes leave out
aliasunder Oct 21, 2026
de16e3e
docs(agents): list the release-notes script test with the other shell…
aliasunder Oct 21, 2026
4e3d58f
docs(agents): fix the run-on sentence in the shell-script test exception
aliasunder Oct 21, 2026
fc6c3b1
ci(release): list commits whose manifests the dev-dependency check ca…
aliasunder Oct 21, 2026
2adf590
docs: say the release workflows push their own commits to main
aliasunder Oct 21, 2026
2c8ecc1
ci(release): list a commit git cannot diff, and allow overrides in th…
aliasunder Oct 21, 2026
62f56da
ci(release): skip a deps-dev or hooks commit before looking up its paths
aliasunder Oct 21, 2026
f063b72
docs: say only a tag push's version bump lands via PR
aliasunder Oct 21, 2026
2548d20
ci(release): keep .dockerignore-only commits in server release notes
aliasunder Oct 21, 2026
ce6a003
test(release): cover package.json-only and lockfile-only dev-dependen…
aliasunder Oct 21, 2026
277f0b0
ci(release): list .env.example-only commits, and a lockfile entry tha…
aliasunder Oct 21, 2026
739c0a5
ci(release): count a lockfile entry added as null as a change
aliasunder Oct 21, 2026
b08b69a
docs(agents): name the changelog script's test with the other shell-s…
aliasunder Oct 21, 2026
2d8a166
test(release): assert the whole run for a lockfile entry that isn't a…
aliasunder Oct 21, 2026
b73a878
test(release): cover a release whose every commit is left out
aliasunder Oct 21, 2026
a3e0213
docs: write the release paths in CONTRIBUTING.md as full sentences
aliasunder Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 177 additions & 39 deletions .github/scripts/generate-notes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
# Usage: generate-notes.sh <new-tag> [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
Expand All @@ -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 `<subject>\x1f<body>`, 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 `<hash>\x1f<subject>\x1f<body>`,
# 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"),
Expand All @@ -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<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<description>.+)$'
)
# 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.
Expand All @@ -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 = {}
Expand Down Expand Up @@ -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]
Comment thread
aliasunder marked this conversation as resolved.

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
Comment thread
aliasunder marked this conversation as resolved.
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
Expand All @@ -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
17 changes: 10 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
aliasunder marked this conversation as resolved.
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,
Expand Down
32 changes: 26 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version> && 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<version> && 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
Expand Down
2 changes: 1 addition & 1 deletion DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version> && 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

Expand Down
Loading
Loading