Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 24 additions & 0 deletions .github/workflows/release-note.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 - <reason>` 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

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ 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

- 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

Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/tests/cli-human.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
25 changes: 13 additions & 12 deletions docs/release/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -72,17 +72,17 @@ The trusted publisher for `recallbase` is configured as:

Run the `Release` workflow manually with `version=v<package-version>` 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 `## [<package-version>]` 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 - <reason>` 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.
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
40 changes: 40 additions & 0 deletions scripts/check-release-note.ts
Original file line number Diff line number Diff line change
@@ -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 - <reason>' 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();
150 changes: 150 additions & 0 deletions scripts/prepare-release.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<PrepareReleaseOptions, "rootDir"> {
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=<semver>.");
return { version, date };
}

async function main(): Promise<void> {
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();
Loading