From 5011e7e76b82eaafff0b99876812cd9d4dda2f36 Mon Sep 17 00:00:00 2001 From: DarinRowe <5706314+DarinRowe@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:41:36 -0700 Subject: [PATCH] fix(release): require complete release notes --- .github/workflows/release-note.yml | 24 ++++ .github/workflows/release.yml | 1 + AGENTS.md | 1 + CHANGELOG.md | 9 ++ apps/cli/tests/cli-human.test.ts | 3 +- docs/release/platforms.md | 25 ++-- package.json | 2 + scripts/check-release-note.ts | 40 +++++++ scripts/prepare-release.ts | 150 ++++++++++++++++++++++++ tests/packaging/release-process.test.ts | 84 +++++++++++++ 10 files changed, 326 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/release-note.yml create mode 100644 scripts/check-release-note.ts create mode 100644 scripts/prepare-release.ts create mode 100644 tests/packaging/release-process.test.ts diff --git a/.github/workflows/release-note.yml b/.github/workflows/release-note.yml new file mode 100644 index 0000000..a1f757c --- /dev/null +++ b/.github/workflows/release-note.yml @@ -0,0 +1,24 @@ +name: Release Note + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + +jobs: + release-note: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + - name: Require a release-note decision + env: + RELEASE_NOTE_BASE_REF: ${{ github.event.pull_request.base.sha }} + RELEASE_NOTE_PR_BODY: ${{ github.event.pull_request.body }} + run: bun run check:release-note diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1698d38..4c8969a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -208,6 +208,7 @@ jobs: run: | if gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release upload "$VERSION" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" + gh release edit "$VERSION" --notes-file release-assets/release-notes.md --repo "$GITHUB_REPOSITORY" else gh release create "$VERSION" release-assets/* \ --repo "$GITHUB_REPOSITORY" \ diff --git a/AGENTS.md b/AGENTS.md index 3781301..2a6fb71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ The browser extension source lives in a sibling project. This repository still o - Keep user-facing CLI output useful, not just raw IDs. - Preserve privacy and local-first boundaries. - When touching native host protocol, verify compatibility with the browser extension project. +- For every pull request, update `CHANGELOG.md` under `[Unreleased]` or put `Release-Note: none - ` in the pull request body. Prepare a release with `bun run release:prepare -- --version=X.Y.Z` so changelog promotion and version metadata move together. ## Review Priorities diff --git a/CHANGELOG.md b/CHANGELOG.md index a2542ff..64829cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Pull requests now require an explicit release-note decision, and one release preparation command moves all accumulated changes while synchronizing version metadata. + ## [0.1.8] - 2026-08-21 ### Added @@ -14,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Native-host installation now automatically discovers established Chromium forks on macOS and Linux, plus existing per-user native-messaging roots on Windows. - Added persistent explicit registration for nonstandard Chromium forks, including verify and clear operations, so new forks do not require code changes. - Installed Firefox channels are now reported individually while sharing Mozilla's native-messaging manifest. +- Added local Cursor Desktop and Agent CLI history import from main-agent transcripts with deduplication, incremental updates, and explicit root support. ### Fixed @@ -24,6 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - GitHub Release notes are now generated from the matching changelog version, and stable packaging fails when that entry is missing or empty. - CI and release smoke tests now exercise compiled native hosts and browser-fork registration across macOS, Linux, and Windows. +### Security + +- Cursor imports exclude subagents, thinking, tool and status payloads, terminal and file contents, opaque CLI data, application state, derived indexes, and cloud or background history. + ## [0.1.7] - 2026-08-21 ### Added diff --git a/apps/cli/tests/cli-human.test.ts b/apps/cli/tests/cli-human.test.ts index 0e2476c..f1dda3c 100644 --- a/apps/cli/tests/cli-human.test.ts +++ b/apps/cli/tests/cli-human.test.ts @@ -4,12 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { LocalDatabase } from "@recallbase/core"; import { defaultArgv, isCliEntrypoint, runCommand } from "../src/cli"; +import packageJson from "../../../package.json"; describe("CLI human output", () => { test("reports the package version without opening the database", async () => { const result = await runCommand(["--version"], { ...process.env, RECALLBASE_DB: "/not/a/real/database.sqlite" }); - expect(result).toEqual({ code: 0, stdout: "recallbase 0.1.8\n" }); + expect(result).toEqual({ code: 0, stdout: `recallbase ${packageJson.version}\n` }); }); test("shows subcommand help without opening the database or searching", async () => { diff --git a/docs/release/platforms.md b/docs/release/platforms.md index 17d6a6d..0aa264b 100644 --- a/docs/release/platforms.md +++ b/docs/release/platforms.md @@ -53,14 +53,14 @@ rb backup --out recallbase-backup.json --json - Native-host CI targets: real compiled host installs on macOS latest, Windows latest, and Linux latest, plus POSIX source-host execution with a GUI-safe `PATH`. - GitHub Release and npm publishing are gated on executing each exact packaged native-host binary and the user-facing npm shim on Linux x64/arm64, macOS Intel/Apple Silicon, and Windows x64 runners. - GitHub Release CLI targets: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, and `win32-x64`. -- npm targets: one package, `recallbase`, with the public version plus platform versions such as `0.1.7-darwin-arm64`, `0.1.7-linux-x64`, and `0.1.7-win32-x64`. +- npm targets: one package, `recallbase`, with the public version plus platform versions such as `X.Y.Z-darwin-arm64`, `X.Y.Z-linux-x64`, and `X.Y.Z-win32-x64`. - Native-host install targets: Chrome, Chromium, Microsoft Edge, and Firefox per-user locations on macOS/Linux/Windows; Chrome for Testing per-user locations on macOS/Linux; safely auto-discovered Chromium profile directories on macOS/Linux; standard plus already-established browser-specific Windows HKCU registrations with compiled `rb.exe`; and persistent explicit user-data/registry targets for every Chromium fork whose product-specific location cannot be inferred safely. - Linux prebuilt targets require glibc. musl/Alpine users must currently run the package source with Bun; do not route them to the glibc binary. - Source fallback target: any platform that can run Bun 1.3.14 and satisfy the SQLite/FTS requirement. ## Publishing Notes -`recallbase` is available on npm as of May 22, 2026. npm publishing uses Trusted Publishing/OIDC, not a long-lived npm token. The package follows the Codex CLI pattern: `recallbase@0.1.7` is the command shim, and platform binaries are separate versions of the same package referenced through npm alias optional dependencies. +`recallbase` is available on npm as of May 22, 2026. npm publishing uses Trusted Publishing/OIDC, not a long-lived npm token. The package follows the Codex CLI pattern: `recallbase@X.Y.Z` is the command shim, and platform binaries are separate versions of the same package referenced through npm alias optional dependencies. The trusted publisher for `recallbase` is configured as: @@ -72,17 +72,17 @@ The trusted publisher for `recallbase` is configured as: Run the `Release` workflow manually with `version=v` and `publish_npm=true`. The workflow uses Node 24, npm 11+, and `id-token: write` to publish platform versions first, then the `recallbase` shim version. Platform versions are published with per-target dist-tags (`darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `win32-x64`); the shim version is published as `latest`. -`CHANGELOG.md` is the single source for user-facing changes. Stable packaging extracts the exact `## []` section into the GitHub Release notes and fails before publishing when that section is missing or empty. Test releases keep generic dogfooding notes and do not require a versioned changelog entry. +`CHANGELOG.md` is the single source for user-facing changes. Every pull request must update `[Unreleased]` or include `Release-Note: none - ` in its body; the lightweight `Release Note` workflow enforces that decision before merge. Prepare a stable version with `bun run release:prepare -- --version=X.Y.Z`. The command moves the complete `[Unreleased]` section into the new version and synchronizes package, CLI, plugin, Gemini, and lockfile versions. Stable packaging then extracts that exact version section into GitHub Release notes and fails when it is missing or empty. Test releases keep generic dogfooding notes and do not require a versioned changelog entry. The current npm dist-tags should look like: ```text -latest -> 0.1.7 -darwin-arm64 -> 0.1.7-darwin-arm64 -darwin-x64 -> 0.1.7-darwin-x64 -linux-arm64 -> 0.1.7-linux-arm64 -linux-x64 -> 0.1.7-linux-x64 -win32-x64 -> 0.1.7-win32-x64 +latest -> X.Y.Z +darwin-arm64 -> X.Y.Z-darwin-arm64 +darwin-x64 -> X.Y.Z-darwin-x64 +linux-arm64 -> X.Y.Z-linux-arm64 +linux-x64 -> X.Y.Z-linux-x64 +win32-x64 -> X.Y.Z-win32-x64 ``` The first npm publish was completed manually with a short-lived token because npm requires an existing package before Trusted Publishing can be configured. Future publishes should use the workflow path above. @@ -97,9 +97,10 @@ Homebrew distribution is not public yet. Do not advertise a `brew install` comma Manual release packaging commands: ```bash -bun run scripts/package-release.ts --version=v0.1.7 --cli-targets=all -bun run scripts/package-npm.ts --version=0.1.7 --targets=all -bun run scripts/package-homebrew.ts --version=v0.1.7 --repo=DarinRowe/RecallBase +bun run release:prepare -- --version=X.Y.Z +bun run scripts/package-release.ts --version=vX.Y.Z --cli-targets=all +bun run scripts/package-npm.ts --version=X.Y.Z --targets=all +bun run scripts/package-homebrew.ts --version=vX.Y.Z --repo=DarinRowe/RecallBase ``` ## Known Limits diff --git a/package.json b/package.json index 10d4bba..48f4feb 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "package:homebrew": "bun run scripts/package-homebrew.ts", "package:release": "bun run scripts/package-release.ts --cli-targets=all", "package:release:test": "bun run scripts/package-release.ts --test", + "check:release-note": "bun run scripts/check-release-note.ts", + "release:prepare": "bun run scripts/prepare-release.ts", "lint": "bunx tsc -b --pretty false", "perf": "bun test tests/perf" }, diff --git a/scripts/check-release-note.ts b/scripts/check-release-note.ts new file mode 100644 index 0000000..0d57710 --- /dev/null +++ b/scripts/check-release-note.ts @@ -0,0 +1,40 @@ +const NONE_MARKER = /^Release-Note: none - \S.*$/m; + +export type ReleaseNoteDecision = "changelog" | "none"; + +export function validateReleaseNote(changedFiles: string[], pullRequestBody: string): ReleaseNoteDecision { + if (changedFiles.includes("CHANGELOG.md")) return "changelog"; + if (NONE_MARKER.test(pullRequestBody)) return "none"; + + throw new Error( + "Every pull request must update CHANGELOG.md under [Unreleased] or include " + + "'Release-Note: none - ' in the pull request body." + ); +} + +function changedFiles(base: string): string[] { + return [...new Set([ + ...gitDiff(["--name-only", `${base}...HEAD`], base), + ...gitDiff(["--name-only"], base), + ...gitDiff(["--cached", "--name-only"], base) + ])]; +} + +function gitDiff(args: string[], base: string): string[] { + const result = Bun.spawnSync(["git", "diff", ...args], { + stdout: "pipe", + stderr: "pipe" + }); + if (!result.success) { + throw new Error(new TextDecoder().decode(result.stderr).trim() || `Could not compare against ${base}.`); + } + return new TextDecoder().decode(result.stdout).split("\n").filter(Boolean); +} + +function main(): void { + const base = process.env.RELEASE_NOTE_BASE_REF ?? "origin/main"; + const decision = validateReleaseNote(changedFiles(base), process.env.RELEASE_NOTE_PR_BODY ?? ""); + console.log(decision === "changelog" ? "CHANGELOG.md updated." : "Release-Note: none accepted."); +} + +if (import.meta.main) main(); diff --git a/scripts/prepare-release.ts b/scripts/prepare-release.ts new file mode 100644 index 0000000..65f7b99 --- /dev/null +++ b/scripts/prepare-release.ts @@ -0,0 +1,150 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +type PrepareReleaseOptions = { + rootDir: string; + version: string; + date: string; +}; + +const versionJsonFiles = [ + "package.json", + "apps/cli/package.json", + ".claude-plugin/plugin.json", + "gemini-extension.json" +]; + +export async function prepareRelease(options: PrepareReleaseOptions): Promise { + const nextVersion = normalizeVersion(options.version); + const packagePath = join(options.rootDir, "package.json"); + const packageText = await readFile(packagePath, "utf8"); + const currentVersion = readJsonVersion(packageText, "package.json"); + assertNewerVersion(currentVersion, nextVersion); + + const changelogPath = join(options.rootDir, "CHANGELOG.md"); + const lockPath = join(options.rootDir, "bun.lock"); + const changelog = prepareChangelog( + await readFile(changelogPath, "utf8"), + currentVersion, + nextVersion, + options.date + ); + const lock = updateCliWorkspaceVersion(await readFile(lockPath, "utf8"), currentVersion, nextVersion); + const jsonUpdates = await Promise.all(versionJsonFiles.map(async (path) => { + const absolutePath = join(options.rootDir, path); + const content = path === "package.json" ? packageText : await readFile(absolutePath, "utf8"); + return { absolutePath, content: updateJsonVersion(content, currentVersion, nextVersion, path) }; + })); + + await Promise.all([ + writeFile(changelogPath, changelog), + writeFile(lockPath, lock), + ...jsonUpdates.map(({ absolutePath, content }) => writeFile(absolutePath, content)) + ]); +} + +export function prepareChangelog( + changelog: string, + currentVersion: string, + nextVersion: string, + date: string +): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid release date '${date}'. Expected YYYY-MM-DD.`); + if (new RegExp(`^## \\[${escapeRegExp(nextVersion)}\\]`, "m").test(changelog)) { + throw new Error(`CHANGELOG.md already contains version ${nextVersion}.`); + } + + const unreleased = /^## \[Unreleased\][ \t]*$/m.exec(changelog); + if (!unreleased) throw new Error("CHANGELOG.md is missing ## [Unreleased]."); + const afterHeadingIndex = unreleased.index + unreleased[0].length; + const afterHeading = changelog.slice(afterHeadingIndex); + const nextHeadingOffset = afterHeading.search(/^##\s+/m); + if (nextHeadingOffset < 0) throw new Error("CHANGELOG.md has no released version after ## [Unreleased]."); + const changes = afterHeading.slice(0, nextHeadingOffset).trim(); + if (!changes) throw new Error("CHANGELOG.md ## [Unreleased] is empty."); + + const released = afterHeading.slice(nextHeadingOffset); + const currentHeading = new RegExp(`^## \\[${escapeRegExp(currentVersion)}\\](?:\\s+-\\s+.+)?[ \\t]*$`, "m"); + if (!currentHeading.test(released)) { + throw new Error(`CHANGELOG.md does not contain current package version ${currentVersion} after [Unreleased].`); + } + + const unreleasedLink = new RegExp( + `^\\[Unreleased\\]: https://github\\.com/DarinRowe/RecallBase/compare/v${escapeRegExp(currentVersion)}\\.\\.\\.HEAD$`, + "m" + ); + if (!unreleasedLink.test(changelog)) { + throw new Error(`CHANGELOG.md [Unreleased] link does not start at v${currentVersion}.`); + } + + const body = `${changelog.slice(0, afterHeadingIndex)}\n\n## [${nextVersion}] - ${date}\n\n${changes}\n\n${released.trimStart()}`; + return body.replace( + unreleasedLink, + `[Unreleased]: https://github.com/DarinRowe/RecallBase/compare/v${nextVersion}...HEAD\n` + + `[${nextVersion}]: https://github.com/DarinRowe/RecallBase/compare/v${currentVersion}...v${nextVersion}` + ); +} + +function updateJsonVersion(content: string, currentVersion: string, nextVersion: string, path: string): string { + const parsed = JSON.parse(content) as { version?: unknown }; + if (parsed.version !== currentVersion) { + throw new Error(`${path} version must be ${currentVersion}, found ${String(parsed.version)}.`); + } + parsed.version = nextVersion; + return `${JSON.stringify(parsed, null, 2)}\n`; +} + +function updateCliWorkspaceVersion(content: string, currentVersion: string, nextVersion: string): string { + const pattern = /(\"apps\/cli\":\s*\{[\s\S]*?\"version\":\s*\")([^\"]+)(\")/; + const match = pattern.exec(content); + if (!match || match[2] !== currentVersion) { + throw new Error(`bun.lock apps/cli version must be ${currentVersion}.`); + } + return content.replace(pattern, `$1${nextVersion}$3`); +} + +function readJsonVersion(content: string, path: string): string { + const version = (JSON.parse(content) as { version?: unknown }).version; + if (typeof version !== "string") throw new Error(`${path} has no string version.`); + return normalizeVersion(version); +} + +function normalizeVersion(version: string): string { + const normalized = version.replace(/^v/, ""); + if (!/^\d+\.\d+\.\d+$/.test(normalized)) throw new Error(`Invalid release version '${version}'.`); + return normalized; +} + +function assertNewerVersion(currentVersion: string, nextVersion: string): void { + const current = currentVersion.split(".").map(Number); + const next = nextVersion.split(".").map(Number); + for (let index = 0; index < 3; index += 1) { + if (next[index]! > current[index]!) return; + if (next[index]! < current[index]!) break; + } + throw new Error(`Release version ${nextVersion} must be newer than ${currentVersion}.`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function parseOptions(args: string[]): Omit { + let version: string | undefined; + let date = new Date().toISOString().slice(0, 10); + for (const arg of args) { + if (arg.startsWith("--version=")) version = arg.slice("--version=".length); + else if (arg.startsWith("--date=")) date = arg.slice("--date=".length); + else throw new Error(`Unknown prepare-release option: ${arg}`); + } + if (!version) throw new Error("prepare-release requires --version=."); + return { version, date }; +} + +async function main(): Promise { + const options = parseOptions(process.argv.slice(2)); + await prepareRelease({ rootDir: process.cwd(), ...options }); + console.log(`Prepared RecallBase ${normalizeVersion(options.version)}.`); +} + +if (import.meta.main) await main(); diff --git a/tests/packaging/release-process.test.ts b/tests/packaging/release-process.test.ts new file mode 100644 index 0000000..8e0130e --- /dev/null +++ b/tests/packaging/release-process.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { validateReleaseNote } from "../../scripts/check-release-note"; +import { prepareChangelog, prepareRelease } from "../../scripts/prepare-release"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe("release-note policy", () => { + test("requires either an Unreleased changelog edit or an explicit no-note reason", () => { + expect(validateReleaseNote(["CHANGELOG.md", "src/feature.ts"], "")).toBe("changelog"); + expect(validateReleaseNote(["src/internal.ts"], "Release-Note: none - Internal refactor only.")).toBe("none"); + expect(() => validateReleaseNote(["src/feature.ts"], "")).toThrow("Every pull request must update CHANGELOG.md"); + expect(() => validateReleaseNote(["src/feature.ts"], "Release-Note: none")).toThrow(""); + }); +}); + +describe("release preparation", () => { + test("moves Unreleased changes into one version and updates all release metadata", async () => { + const rootDir = await fixtureRoot(); + + await prepareRelease({ rootDir, version: "v0.1.9", date: "2026-08-22" }); + + const changelog = await readFile(join(rootDir, "CHANGELOG.md"), "utf8"); + expect(changelog).toContain("## [Unreleased]\n\n## [0.1.9] - 2026-08-22\n\n### Added\n\n- Added a feature."); + expect(changelog).toContain("[Unreleased]: https://github.com/DarinRowe/RecallBase/compare/v0.1.9...HEAD"); + expect(changelog).toContain("[0.1.9]: https://github.com/DarinRowe/RecallBase/compare/v0.1.8...v0.1.9"); + for (const path of ["package.json", "apps/cli/package.json", ".claude-plugin/plugin.json", "gemini-extension.json"]) { + expect(JSON.parse(await readFile(join(rootDir, path), "utf8")).version).toBe("0.1.9"); + } + expect(await readFile(join(rootDir, "bun.lock"), "utf8")).toContain('"version": "0.1.9"'); + }); + + test("rejects an empty Unreleased section before changing files", async () => { + const changelog = fixtureChangelog().replace("### Added\n\n- Added a feature.\n\n", ""); + expect(() => prepareChangelog(changelog, "0.1.8", "0.1.9", "2026-08-22")) + .toThrow("## [Unreleased] is empty"); + }); +}); + +async function fixtureRoot(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), "recallbase-release-process-")); + tempDirs.push(rootDir); + await mkdir(join(rootDir, "apps/cli"), { recursive: true }); + await mkdir(join(rootDir, ".claude-plugin"), { recursive: true }); + for (const path of ["package.json", "apps/cli/package.json", ".claude-plugin/plugin.json", "gemini-extension.json"]) { + await writeFile(join(rootDir, path), `${JSON.stringify({ name: "recallbase", version: "0.1.8" }, null, 2)}\n`); + } + await writeFile(join(rootDir, "bun.lock"), `{ + "workspaces": { + "apps/cli": { + "name": "recallbase", + "version": "0.1.8", + }, + }, +}\n`); + await writeFile(join(rootDir, "CHANGELOG.md"), fixtureChangelog()); + return rootDir; +} + +function fixtureChangelog(): string { + return `# Changelog + +## [Unreleased] + +### Added + +- Added a feature. + +## [0.1.8] - 2026-08-21 + +### Added + +- Previous feature. + +[Unreleased]: https://github.com/DarinRowe/RecallBase/compare/v0.1.8...HEAD +[0.1.8]: https://github.com/DarinRowe/RecallBase/compare/v0.1.7...v0.1.8 +`; +}