diff --git a/.github/scripts/generate-cli-notes.sh b/.github/scripts/generate-cli-notes.sh new file mode 100755 index 000000000..4c9202d0a --- /dev/null +++ b/.github/scripts/generate-cli-notes.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# List the commits since the previous CLI tag that change what a CLI user gets, +# one `- ` line each, newest first. +# Usage: generate-cli-notes.sh +# +# A commit is listed when its diff under cli/ adds or removes a line that none +# of these rules skips: +# - blank lines +# - lines in files under cli/src/__tests__/ +# - the "version" line of cli/package.json (the release bump) +# - in .ts files, lines that start, after any indent, with #, //, /* or *: +# comments, and the comment text of the .env templates in cli/src/env.ts. +# A # line shaped like a setting (# KEY=value) still counts: the templates +# ship optional settings commented out, for the user to uncomment. + +set -euo pipefail + +NEW_TAG="${1:?Usage: generate-cli-notes.sh }" +# --match keeps server tags (v0.55.0) out. With no earlier CLI tag, the range +# is every commit reachable from new-tag. +PREV_TAG=$(git describe --tags --abbrev=0 --match "cli-v*" "$NEW_TAG"^ 2> /dev/null || echo "") + +if [ -n "$PREV_TAG" ]; then + RANGE="${PREV_TAG}..${NEW_TAG}" +else + RANGE="$NEW_TAG" +fi + +# Exits 0 when the patch on stdin adds or removes a counted line. +has_counted_change() { + awk ' + # A file header comes before its first @@ line, so a content line such as + # "--- x" (a removed "-- x") is never read as a header. + /^diff --git / { in_hunk = 0; path = ""; next } + /^@@/ { in_hunk = 1; next } + # Git quotes a name holding special characters and ends the header with a + # tab when the name holds a space. A /dev/null header (an added or deleted + # file) fails the match, so path comes from the other header. + !in_hunk && /^(---|\+\+\+) "?[ab]\// { + path = $0 + sub(/^(---|\+\+\+) "?[ab]\//, "", path) + sub(/"?\t?$/, "", path) + next + } + !in_hunk { next } + /^[+-]/ { + content = substr($0, 2) + if (content ~ /^[ \t\r]*$/) next + if (path == "cli/package.json" && content ~ /^[ \t]*"version"[ \t]*:/) next + # Before the # skip below, so a commented-out setting (# KEY=value) counts. + if (path ~ /\.ts$/ && content ~ /^[ \t]*#[ \t]*[A-Z][A-Z0-9_]*=/) { found = 1; exit } + if (path ~ /\.ts$/ && content ~ /^[ \t]*(#|\/\/|\/\*|\*)/) next + found = 1 + exit + } + END { exit found ? 0 : 1 } + ' +} + +CLI_PATHSPEC=(cli/ ':(exclude)cli/src/__tests__/') + +# Hex hashes only, so word splitting in the loop below is safe. Merge commits +# are skipped: the commits a merge brings in are checked on their own. +COMMIT_HASHES=$(git log --no-merges --format=%H "$RANGE" -- "${CLI_PATHSPEC[@]}") + +for commit_hash in $COMMIT_HASHES; do + PATCH=$(git diff-tree -p --root --no-commit-id -U0 "$commit_hash" -- "${CLI_PATHSPEC[@]}") + if has_counted_change <<< "$PATCH"; then + git show -s --format='- %s' "$commit_hash" + fi +done diff --git a/.github/workflows/cli_release.yml b/.github/workflows/cli_release.yml index 38835f8ca..e1a56e4a6 100644 --- a/.github/workflows/cli_release.yml +++ b/.github/workflows/cli_release.yml @@ -220,29 +220,28 @@ jobs: echo "\`\`\`" } >> "$GITHUB_STEP_SUMMARY" - # --latest=false keeps the "Latest" badge on server releases. Notes - # list only cli/ commits — server commits interleave on main, so - # range-wide auto-notes would include unrelated work. If this step - # fails after a successful publish, create the release manually - # (re-running the workflow would refuse to republish the version). + # - --latest=false keeps the "Latest" badge on server releases. + # - Server commits interleave on main, so GitHub's --generate-notes + # would list them too; generate-cli-notes.sh lists only commits that + # change what a CLI user gets. + # - If this step fails after a successful publish, run its commands by + # hand with NEXT set to the version: re-running the workflow would + # refuse to republish it. - name: Create GitHub release if: inputs.bump != 'beta' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NEXT: ${{ steps.next.outputs.version }} run: | + # "Refuse to republish an existing version" stops every repeat after + # a publish, so this skip is reached only for a release created by + # hand before the version reached npm. if gh release view "cli-v$NEXT" > /dev/null 2>&1; then echo "Release cli-v$NEXT already exists; skipping" exit 0 fi - PREV=$(git describe --tags --match "cli-v*" --abbrev=0 "cli-v$NEXT"^ 2> /dev/null || true) - if [ -n "$PREV" ]; then - RANGE="$PREV..cli-v$NEXT" - else - RANGE="cli-v$NEXT" - fi { - git log "$RANGE" --pretty="- %s" -- cli/ + bash .github/scripts/generate-cli-notes.sh "cli-v$NEXT" echo "" echo "Published to npm: https://www.npmjs.com/package/vault-cortex/v/$NEXT" } > /tmp/release-notes.md diff --git a/AGENTS.md b/AGENTS.md index 0a18c16ff..db7954c0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -943,32 +943,37 @@ Two naming layers — MCP (JSON wire format) and TypeScript (internal): centralized test directory higher up the tree. Don't spawn a standalone test file just to mock differently; use `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, 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`, - `check-sync-vault.test.ts`, which covers the vault check - `init-setup-vault` runs, and `print-derived-env.test.ts`, which covers - the derivation that `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 - 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, - the `container_environment` files the chain publishes, the volume - layout, and the checks that stop the container) belongs in the - remote-boot test suite (`src/__tests__/docker/`, see "Remote image - boot tests — when to add"); the branches inside one script stay in - that script's test file. + + **Exception — shell scripts outside vitest's include paths.** These + scripts are not TypeScript modules, and vitest's include paths (`src/`, + `cli/src/`, `scripts/`) reach none of their folders, so their tests + live in folders vitest reaches: + - the remote image's s6 init scripts in + `rootfs/etc/s6-overlay/scripts/` (the `vault-mcp` server's boot chain + for the `:remote` target) → `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`, `check-sync-vault.test.ts` (the vault + check `init-setup-vault` runs), and `print-derived-env.test.ts` (the + derivation that `init-derive-env` publishes) + - the Claude Code hook in `.claude/hooks/` → + `src/__tests__/install-deps.test.ts` + - the release scripts in `.github/scripts/` → `src/__tests__/`: + `generate-notes.test.ts`, `generate-cli-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 `generate-notes.sh` and `generate-cli-notes.sh`, 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, the + `container_environment` files the chain publishes, the volume layout, + and the checks that stop the container) belongs in the remote-boot test + suite (`src/__tests__/docker/`, see "Remote image boot tests — when to + add"); the branches inside one script stay in that script's test file. + - Separate `it()` blocks over callback-pattern `it.each` when assertions are structurally different — `it.each` is for genuinely identical assertion shapes (input → expected). Use labeled case objects diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d2f798be..a16ff90ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -254,8 +254,20 @@ What each release's notes list: 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). +- **CLI releases** (`.github/scripts/generate-cli-notes.sh`): every commit + that changes `cli/`, including dependency bumps that edit + `cli/package.json`, except commits whose added and removed lines under + `cli/` are all: + - blank + - in files under `cli/src/__tests__/` + - the `"version"` line of `cli/package.json` (the release bump) + - lines in `.ts` files that start, after any indent, with `#`, `//`, `/*` + or `*`: comments, and the comment text of the `.env` templates in + `cli/src/env.ts`. A `#` line shaped like a setting (`# KEY=value`) still + counts: the templates ship optional settings commented out, for the user + to uncomment. + + In other files, `cli/README.md` included, every non-blank line counts. See the [DEPLOY.md CI/CD section](./DEPLOY.md#cicd) for details on each workflow. diff --git a/scripts/__tests__/lint-shell.test.ts b/scripts/__tests__/lint-shell.test.ts index c82883b62..e168b428c 100644 --- a/scripts/__tests__/lint-shell.test.ts +++ b/scripts/__tests__/lint-shell.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it, onTestFinished } from "vitest" /** Every shell script in the repo, sorted: what a run with no paths checks. */ const ALL_SHELL_SCRIPTS = [ ".claude/hooks/install-deps.sh", + ".github/scripts/generate-cli-notes.sh", ".github/scripts/generate-notes.sh", ".github/scripts/update-changelog.sh", "cli/src/__tests__/integration/fixtures/docker", diff --git a/src/__tests__/generate-cli-notes.test.ts b/src/__tests__/generate-cli-notes.test.ts new file mode 100644 index 000000000..af3d05d79 --- /dev/null +++ b/src/__tests__/generate-cli-notes.test.ts @@ -0,0 +1,292 @@ +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 CLI release-notes generator + * (.github/scripts/generate-cli-notes.sh): which commits between two CLI tags + * the notes list. The real script runs under `bash` with real git, in a fresh + * repository per test. + */ + +const SCRIPT_PATH = resolve(import.meta.dirname, "../../.github/scripts/generate-cli-notes.sh") + +const runnerPath = (): string => { + const path = process.env.PATH + + if (!path) throw new Error("the release-notes tests need a PATH") + return path +} + +const cliManifest = ({ version, zod }: { version: string; zod: string }): string => { + const manifest = { name: "vault-cortex", version, dependencies: { zod } } + return `${JSON.stringify(manifest, null, 2)}\n` +} + +/** Shaped like cli/src/env.ts: .env template text inside a template literal, + * where a line starting with # is a comment. */ +const envTemplateModule = ({ comment, setting }: { comment: string; setting: string }): string => { + return `export const TEMPLATE = \`\n${comment}\n${setting}\n\`\n` +} + +/** File contents by repository path; null deletes the file. */ +type FileChanges = Record + +type NotesRun = { status: number | null; stdout: string; stderr: string } + +type CliReleaseRepo = { + commit: (message: string, changes: FileChanges) => void + /** Tags HEAD as cli-v0.2.0 and runs the script for it. */ + generateNotes: () => NotesRun +} + +/** A repository whose first commit holds a small cli/ package and a server + * file, tagged firstCommitTag. */ +const createCliReleaseRepo = ({ + firstCommitTag = "cli-v0.1.0", +}: { firstCommitTag?: string } = {}): CliReleaseRepo => { + // realpath: macOS's tmpdir is a symlink. + const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "generate-cli-notes-"))) + onTestFinished(() => rmSync(tempDir, { recursive: true, force: true })) + const repoDir = join(tempDir, "repo") + + // Built from scratch, so the runner's git config (signing, hooks, diff + // prefixes) never reaches 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): void => { + 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) + } + + mkdirSync(repoDir) + git("init", "--quiet") + commit("feat: first release", { + "cli/package.json": cliManifest({ version: "0.1.0", zod: "^1.0.0" }), + "cli/README.md": "# vault-cortex\n", + "cli/src/env.ts": envTemplateModule({ + comment: "# Path to the vault", + setting: "VAULT_PATH=/vault", + }), + "cli/src/program.ts": "export const flag = 1\n", + "cli/src/__tests__/program.test.ts": "export const checks = 1\n", + "src/server.ts": "export const server = 1\n", + }) + git("tag", firstCommitTag) + + const generateNotes = (): NotesRun => { + git("tag", "cli-v0.2.0") + const result = spawnSync("bash", [SCRIPT_PATH, "cli-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 lists nothing cannot pass a + * test that expects another commit to be left out. */ +const commitListedCodeChange = (repo: CliReleaseRepo): void => { + repo.commit("feat: add a flag", { "cli/src/program.ts": "export const flag = 2\n" }) +} + +const LISTED_CODE_CHANGE_ONLY = { status: 0, stdout: "- feat: add a flag\n", stderr: "" } + +describe("generate-cli-notes.sh", () => { + describe("listed commits", () => { + it("lists a commit that changes code", () => { + const repo = createCliReleaseRepo() + commitListedCodeChange(repo) + + expect(repo.generateNotes()).toEqual(LISTED_CODE_CHANGE_ONLY) + }) + + it("lists a README change, headings included", () => { + const repo = createCliReleaseRepo() + repo.commit("docs(cli): add a usage heading", { + "cli/README.md": "# vault-cortex\n\n## Usage\n", + }) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "- docs(cli): add a usage heading\n", + stderr: "", + }) + }) + + it("lists a dependency change in cli/package.json", () => { + const repo = createCliReleaseRepo() + repo.commit("chore(deps): bump zod", { + "cli/package.json": cliManifest({ version: "0.1.0", zod: "^1.1.0" }), + }) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "- chore(deps): bump zod\n", + stderr: "", + }) + }) + + it("lists a commit that changes comment lines and a code line", () => { + const repo = createCliReleaseRepo() + repo.commit("fix(cli): rename the vault setting", { + "cli/src/env.ts": + "// The local template\n" + + envTemplateModule({ comment: "# Vault folder", setting: "VAULT_DIR=/vault" }), + }) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "- fix(cli): rename the vault setting\n", + stderr: "", + }) + }) + + it("lists a commented-out setting added to a .ts template", () => { + const repo = createCliReleaseRepo() + repo.commit("feat(cli): offer the log level setting", { + "cli/src/env.ts": envTemplateModule({ + comment: "# Path to the vault", + setting: "VAULT_PATH=/vault\n\n# How much the server logs\n# LOG_LEVEL=info", + }), + }) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "- feat(cli): offer the log level setting\n", + stderr: "", + }) + }) + + it("lists a heading added to an .md file git quotes, after .ts comments in the same commit", () => { + const repo = createCliReleaseRepo() + repo.commit("docs(cli): add a guide", { + "cli/src/guide.ts": "// Points to the guide\n", + "cli/ünïcode.md": "# Guide\n", + }) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "- docs(cli): add a guide\n", + stderr: "", + }) + }) + + it("lists every commit from the first when no earlier CLI tag exists, ignoring server tags", () => { + const repo = createCliReleaseRepo({ firstCommitTag: "v0.1.0" }) + commitListedCodeChange(repo) + + expect(repo.generateNotes()).toEqual({ + status: 0, + stdout: "- feat: add a flag\n- feat: first release\n", + stderr: "", + }) + }) + }) + + describe("left-out commits", () => { + it("leaves out a .ts change made only of comment and blank lines", () => { + const repo = createCliReleaseRepo() + commitListedCodeChange(repo) + repo.commit("docs(cli): reword the template comments", { + "cli/src/env.ts": + "// Written to a new .env file\n" + + "/**\n * The local template.\n */\n" + + envTemplateModule({ + comment: "# Folder that holds your vault (default: VAULT_PATH=/vault)", + setting: "VAULT_PATH=/vault\n", + }), + }) + + expect(repo.generateNotes()).toEqual(LISTED_CODE_CHANGE_ONLY) + }) + + it("leaves out a commit that adds comment lines to two .ts files", () => { + const repo = createCliReleaseRepo() + commitListedCodeChange(repo) + repo.commit("docs(cli): explain the template and the flag", { + "cli/src/env.ts": + "// Written to a new .env file\n" + + envTemplateModule({ comment: "# Path to the vault", setting: "VAULT_PATH=/vault" }), + "cli/src/program.ts": "// Read by the start command\nexport const flag = 2\n", + }) + + expect(repo.generateNotes()).toEqual(LISTED_CODE_CHANGE_ONLY) + }) + + it.each([ + { label: "a space", path: "cli/src/env notes.ts" }, + { label: "non-ASCII characters", path: "cli/src/ünïcode.ts" }, + ])("leaves out a comment-only change to a .ts file whose name has $label", ({ path }) => { + const repo = createCliReleaseRepo() + commitListedCodeChange(repo) + repo.commit("docs(cli): add a comment file", { [path]: "// A note\n" }) + + expect(repo.generateNotes()).toEqual(LISTED_CODE_CHANGE_ONLY) + }) + + it("leaves out a commit that deletes a .ts file holding only comments", () => { + const repo = createCliReleaseRepo() + repo.commit("docs(cli): add a note file", { "cli/src/notes.ts": "// A note\n" }) + commitListedCodeChange(repo) + repo.commit("docs(cli): drop the note file", { "cli/src/notes.ts": null }) + + expect(repo.generateNotes()).toEqual(LISTED_CODE_CHANGE_ONLY) + }) + + it("leaves out a commit that changes only the version in cli/package.json", () => { + const repo = createCliReleaseRepo() + commitListedCodeChange(repo) + repo.commit("release(cli): v0.2.0", { + "cli/package.json": cliManifest({ version: "0.2.0", zod: "^1.0.0" }), + }) + + expect(repo.generateNotes()).toEqual(LISTED_CODE_CHANGE_ONLY) + }) + + it("leaves out a commit that changes only CLI tests", () => { + const repo = createCliReleaseRepo() + commitListedCodeChange(repo) + repo.commit("test(cli): cover the flag", { + "cli/src/__tests__/program.test.ts": "export const checks = 2\n", + }) + + expect(repo.generateNotes()).toEqual(LISTED_CODE_CHANGE_ONLY) + }) + + it("leaves out a commit that changes nothing under cli/", () => { + const repo = createCliReleaseRepo() + commitListedCodeChange(repo) + repo.commit("feat: add a server tool", { "src/server.ts": "export const server = 2\n" }) + + expect(repo.generateNotes()).toEqual(LISTED_CODE_CHANGE_ONLY) + }) + }) +})