diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ea8feac --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +What changed in each release of SCN Chat, for the people who use it, run it, or fork it. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/). `docs/releasing.md` says how a release is cut. + +## [Unreleased] + +### Added + +- A file attached to a message, such as a PDF, shows in the chat as the chip it was in the composer, with its name over its kind and size. A PDF opens in a new tab in the browser's own viewer, and any other file downloads under its name. Several files side by side share a row. +- `pnpm release ` cuts a release from this changelog: it dates the Unreleased section, bumps the version, tags the commit, and publishes the GitHub Release with that section as its notes. `docs/releasing.md` has the versioning rules. +- A provider that turns a request away with a rate limit or a server error is tried again for about a minute, and the reply says the provider is busy meanwhile. A key that has run out of credit or budget is not retried, and when it is the admin's key, the message suggests adding your own. + +### Changed + +- A reply's sources sit behind a count, a chip that opens onto a numbered list with one source to a line, rather than badges under the reply. +- A finished reply's steps open onto a quiet rail, with a dot for each step that is green when its tool finished, red when it failed, and grey for a thought, rather than a stack of numbered cards. + +### Fixed + +- Closing a thought, a step, or a reply's sources no longer pauses and then drops at the end. + +## [0.1.1] - 2026-10-15 + +### Changed + +- Selected text is a calm blue rather than the pale amber that search matches and the Stopped badge use. + +### Fixed + +- A footnote link scrolls to its note in the same reply, and the note's way back does the same, rather than opening the page in a new tab. + +## [0.1.0] - 2026-10-15 + +The first tagged release. + +[Unreleased]: https://github.com/Z-Space-Society/scn-chat/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/Z-Space-Society/scn-chat/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/Z-Space-Society/scn-chat/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index 5ccbede..4d5a5f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,7 @@ An AI assistant chat app where each conversation is an atproto permissioned spac - `apps/web` is the TanStack Start app, which the server runs inside Hono. Its code is grouped by feature in `src/features/`, with thin routes in `src/routes/` and cross-feature code in `src/shared/`. `src/parts/` holds the views that draw message parts, and `src/store/` is the browser's local SQLite copy. - `plugins/` holds the shipped plugins. - `docs/architecture.md` is the human-facing architecture overview, and `docs/plugins.md` the plugin author guide. Update them when a change affects what they describe. +- `CHANGELOG.md` records each change a user, admin, or forker would notice: add its line under Unreleased in the same commit. `docs/releasing.md` has the versioning rules and the steps for cutting a release. - `GLOSSARY.md` holds the project's terms, and `docs/adr/` the decisions behind its less obvious choices. Read the relevant ADR before changing what it decided. - `pnpm dev` runs the server, which serves the web app through Vite, as one process, and `pnpm dev --scan` also loads React Scan in the browser. `pnpm preview` builds the web app and runs the production build locally. `pnpm dev:remote` and `pnpm preview:remote` do the same behind a localhost.run tunnel over ssh, with the tunnel's HTTPS URL as `PUBLIC_URL`, for testing from a phone. `pnpm start` runs the production build for a deployment. `pnpm check` runs lint, type checking, and every test. `pnpm codegen` regenerates the lexicon code after a lexicon change, and `pnpm publish-lexicons ` publishes the lexicons from the lexicon authority account. `pnpm keys` prints fresh secrets. - Local setup: copy `.env.example` to `.env`, fill in the secrets from `pnpm keys`, and put your DID in `ADMIN_DIDS`. Plugins, models, roles, and every other runtime setting are configured in the admin area at `/admin`. diff --git a/apps/server/package.json b/apps/server/package.json index c03136e..441a44d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,5 @@ { "name": "@scn-chat/server", - "version": "0.1.0", "private": true, "type": "module", "exports": { diff --git a/apps/web/package.json b/apps/web/package.json index 2a526f3..589f219 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,5 @@ { "name": "@scn-chat/web", - "version": "0.1.0", "private": true, "type": "module", "scripts": { diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..8868c47 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,36 @@ +# Versions and releases + +SCN Chat has one version, the `version` in the root `package.json`, and it follows [Semantic Versioning](https://semver.org/). The workspace packages under `apps/` and `packages/` are private parts of the app and carry no version of their own. Each plugin keeps its own version in its `package.json`, which the admin's plugin list shows, and bumps it when the plugin changes. + +While the version is 0.x, a minor release, such as 0.2.0, carries new features or anything someone running a deployment must act on, such as a new environment variable, a migration that needs attention, or a lexicon change that needs a fresh login. A patch release, such as 0.1.2, carries fixes and small changes only. + +## Recording changes + +`CHANGELOG.md` is the record. Each commit that changes something a user, an admin, or a forker would notice adds a line under **Unreleased**, in the same commit, under one of: + +- **Added** for something new. +- **Changed** for something that works or looks different. +- **Fixed** for a bug that no longer happens. +- **Removed** for something that is gone. +- **Security** for a vulnerability fixed. + +A line says what the person sees, in a sentence or two, like the start of a commit message without its reasoning. A change that only refactors, tests, or tidies leaves the changelog alone, and several commits that build one feature share one line. Anything a deployment must act on says so in its line. + +## Cutting a release + +Pick the version by the rules above, from what Unreleased holds, then run: + +```sh +pnpm release 0.2.0 --dry-run # checks everything and prints the notes, changing nothing +pnpm release 0.2.0 +``` + +It refuses unless you are on `main` with a clean working tree, up to date with `origin/main`, signed in to the GitHub CLI, the version is newer than the current one and has no tag yet, and Unreleased holds something. Then it: + +1. Renames Unreleased in `CHANGELOG.md` to the version and today's date, starts a new empty Unreleased above it, and moves the compare links at the bottom on. +2. Sets the root `package.json` version to match, and commits both as "Release 0.2.0". +3. Tags that commit with an annotated tag, `v0.2.0`, so the tag records who cut it and when. +4. Pushes the commit and the tag together. +5. Publishes a GitHub Release for the tag, titled `v0.2.0`, with that version's section as its notes. + +The code is in `packages/release`. diff --git a/package.json b/package.json index 5055a65..29f9452 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scn-chat", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "packageManager": "pnpm@10.33.0", @@ -22,7 +22,8 @@ "codegen": "pnpm --filter @scn-chat/lexicons codegen", "publish-lexicons": "pnpm --filter @scn-chat/lexicons publish-lexicons", "migrate": "pnpm --filter @scn-chat/server migrate", - "keys": "pnpm --filter @scn-chat/server keys" + "keys": "pnpm --filter @scn-chat/server keys", + "release": "pnpm --filter @scn-chat/release release" }, "devDependencies": { "@biomejs/biome": "2.5.14", diff --git a/packages/lexicons/package.json b/packages/lexicons/package.json index 31fbb4d..097e76c 100644 --- a/packages/lexicons/package.json +++ b/packages/lexicons/package.json @@ -1,6 +1,5 @@ { "name": "@scn-chat/lexicons", - "version": "0.1.0", "private": true, "type": "module", "exports": { diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index af7b8ea..caef756 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -1,6 +1,5 @@ { "name": "@scn-chat/plugin-api", - "version": "0.1.0", "private": true, "type": "module", "exports": { diff --git a/packages/release/package.json b/packages/release/package.json new file mode 100644 index 0000000..22ef4c3 --- /dev/null +++ b/packages/release/package.json @@ -0,0 +1,9 @@ +{ + "name": "@scn-chat/release", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit -p .", + "release": "node src/release-cli.ts" + } +} diff --git a/packages/release/src/release-cli.ts b/packages/release/src/release-cli.ts new file mode 100644 index 0000000..e5ef854 --- /dev/null +++ b/packages/release/src/release-cli.ts @@ -0,0 +1,79 @@ +// pnpm release [--dry-run]: cut a release from CHANGELOG.md's Unreleased section, as +// docs/releasing.md describes. With --dry-run it checks everything and prints the notes, changing +// nothing. +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { cutRelease, ReleaseError } from './release.ts' + +const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim() +const run = (command: string, args: string[]) => + execFileSync(command, args, { cwd: root, encoding: 'utf8' }).trim() +/** Whether a command succeeds, its output kept quiet. */ +const succeeds = (command: string, args: string[]) => { + try { + execFileSync(command, args, { cwd: root, stdio: 'ignore' }) + return true + } catch { + return false + } +} + +function release(args: string[]) { + const version = args.find((arg) => !arg.startsWith('--')) + const dryRun = args.includes('--dry-run') + if (!version) + throw new ReleaseError('Usage: pnpm release [--dry-run], as pnpm release 0.2.0') + + if (run('git', ['branch', '--show-current']) !== 'main') + throw new ReleaseError('Releases are cut from main. Switch to main first.') + if (run('git', ['status', '--porcelain'])) + throw new ReleaseError('The working tree has changes. Commit or stash them first.') + run('git', ['fetch', '--quiet', 'origin', 'main', '--tags']) + if (run('git', ['rev-list', '--count', 'HEAD..origin/main']) !== '0') + throw new ReleaseError('main is behind origin/main. Pull first.') + if (succeeds('git', ['rev-parse', '--quiet', '--verify', `refs/tags/v${version}`])) + throw new ReleaseError(`The tag v${version} already exists.`) + if (!succeeds('gh', ['auth', 'status'])) + throw new ReleaseError('The GitHub CLI is not signed in. Run gh auth login first.') + + const packagePath = join(root, 'package.json') + const changelogPath = join(root, 'CHANGELOG.md') + const pkg = JSON.parse(readFileSync(packagePath, 'utf8')) as { version: string } + const date = new Date().toLocaleDateString('en-CA') + const cut = cutRelease(readFileSync(changelogPath, 'utf8'), pkg.version, version, date) + + console.log(`Release ${version} (${date}), after ${pkg.version}. Its notes:\n\n${cut.notes}`) + if (dryRun) { + console.log('Dry run: nothing was changed.') + return + } + + writeFileSync(changelogPath, cut.changelog) + writeFileSync(packagePath, `${JSON.stringify({ ...pkg, version }, null, 2)}\n`) + run('git', ['add', 'CHANGELOG.md', 'package.json']) + run('git', ['commit', '--quiet', '-m', `Release ${version}`]) + run('git', ['tag', '-a', `v${version}`, '-m', `Release ${version}`]) + run('git', ['push', '--quiet', '--atomic', 'origin', 'main', `v${version}`]) + const notesPath = join(mkdtempSync(join(tmpdir(), 'scn-chat-release-')), 'notes.md') + writeFileSync(notesPath, cut.notes) + const url = run('gh', [ + 'release', + 'create', + `v${version}`, + '--title', + `v${version}`, + '--notes-file', + notesPath, + ]) + console.log(`Released ${version}: ${url}`) +} + +try { + release(process.argv.slice(2)) +} catch (err) { + if (!(err instanceof ReleaseError)) throw err + console.error(err.message) + process.exitCode = 1 +} diff --git a/packages/release/src/release.ts b/packages/release/src/release.ts new file mode 100644 index 0000000..864f383 --- /dev/null +++ b/packages/release/src/release.ts @@ -0,0 +1,45 @@ +/** Why a release can't be cut, worded for whoever ran it. */ +export class ReleaseError extends Error {} + +const parse = (version: string) => version.split('.').map(Number) + +/** Whether `next` comes after `current`, comparing major, then minor, then patch. */ +function isNewer(next: string, current: string) { + const [a, b] = [parse(next), parse(current)] + for (let i = 0; i < 3; i++) { + if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0) + } + return false +} + +const UNRELEASED = '## [Unreleased]\n' +/** Where Unreleased ends: the next version's heading, or the links at the bottom. */ +const SECTION_END = /\n## \[|\n\[Unreleased\]: / +const UNRELEASED_LINK = /^\[Unreleased\]: (\S+)\/compare\/(\S+)\.\.\.HEAD$/m + +/** + * The changelog with Unreleased dated as `version`, a new empty Unreleased above it, and the + * compare links at the bottom moved on, and that version's section as its release notes. + */ +export function cutRelease(changelog: string, current: string, version: string, date: string) { + if (!/^\d+\.\d+\.\d+$/.test(version)) + throw new ReleaseError(`Give the version as major.minor.patch, like 0.2.0, not "${version}".`) + if (!isNewer(version, current)) + throw new ReleaseError(`The version must be newer than ${current}, the current one.`) + const start = changelog.indexOf(UNRELEASED) + if (start === -1) throw new ReleaseError('CHANGELOG.md has no "## [Unreleased]" section.') + const bodyStart = start + UNRELEASED.length + const rest = changelog.slice(bodyStart) + const end = rest.search(SECTION_END) + const body = (end === -1 ? rest : rest.slice(0, end)).trim() + if (!body) throw new ReleaseError('Unreleased is empty, so there is nothing to release.') + const after = end === -1 ? '' : rest.slice(end + 1) + const link = UNRELEASED_LINK.exec(after) + if (!link) throw new ReleaseError('CHANGELOG.md has no "[Unreleased]: …/compare/…...HEAD" link.') + const [line, base, previous] = link + const links = `[Unreleased]: ${base}/compare/v${version}...HEAD\n[${version}]: ${base}/compare/${previous}...v${version}` + return { + changelog: `${changelog.slice(0, start)}${UNRELEASED}\n## [${version}] - ${date}\n\n${body}\n\n${after.replace(line, links)}`, + notes: `${body}\n`, + } +} diff --git a/packages/release/tests/release.test.ts b/packages/release/tests/release.test.ts new file mode 100644 index 0000000..0c1465f --- /dev/null +++ b/packages/release/tests/release.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { cutRelease, ReleaseError } from '../src/release.ts' + +const changelog = `# Changelog + +Intro. + +## [Unreleased] + +### Added + +- A new thing. + +### Fixed + +- A bug. + +## [0.1.1] - 2026-10-15 + +### Fixed + +- An older bug. + +[Unreleased]: https://github.com/o/r/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/o/r/compare/v0.1.0...v0.1.1 +` + +describe('cutRelease', () => { + it('dates Unreleased as the version, opens a new Unreleased, and links both', () => { + const { changelog: next } = cutRelease(changelog, '0.1.1', '0.2.0', '2026-11-02') + expect(next).toBe(`# Changelog + +Intro. + +## [Unreleased] + +## [0.2.0] - 2026-11-02 + +### Added + +- A new thing. + +### Fixed + +- A bug. + +## [0.1.1] - 2026-10-15 + +### Fixed + +- An older bug. + +[Unreleased]: https://github.com/o/r/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/o/r/compare/v0.1.1...v0.2.0 +[0.1.1]: https://github.com/o/r/compare/v0.1.0...v0.1.1 +`) + }) + + it("gives the release's section as its notes", () => { + expect(cutRelease(changelog, '0.1.1', '0.2.0', '2026-11-02').notes).toBe( + '### Added\n\n- A new thing.\n\n### Fixed\n\n- A bug.\n', + ) + }) + + it('refuses a version that is not newer than the current one', () => { + expect(() => cutRelease(changelog, '0.1.1', '0.1.1', '2026-11-02')).toThrow(ReleaseError) + expect(() => cutRelease(changelog, '0.1.1', '0.1.0', '2026-11-02')).toThrow(/newer than 0.1.1/) + expect(() => cutRelease(changelog, '0.9.0', '0.10.0', '2026-11-02')).not.toThrow() + }) + + it('refuses a version that is not plain major.minor.patch', () => { + expect(() => cutRelease(changelog, '0.1.1', 'v0.2.0', '2026-11-02')).toThrow(/like 0.2.0/) + expect(() => cutRelease(changelog, '0.1.1', '0.2', '2026-11-02')).toThrow(/like 0.2.0/) + }) + + it('refuses when Unreleased holds nothing to release', () => { + const empty = changelog.replace(/### Added[\s\S]*?(?=## \[0\.1\.1\])/, '') + expect(() => cutRelease(empty, '0.1.1', '0.2.0', '2026-11-02')).toThrow(/Unreleased is empty/) + }) +}) diff --git a/packages/release/tsconfig.json b/packages/release/tsconfig.json new file mode 100644 index 0000000..71351fd --- /dev/null +++ b/packages/release/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "tests"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c99ca18..4c3c465 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,6 +351,8 @@ importers: specifier: 4.6.5 version: 4.6.5 + packages/release: {} + plugins/anthropic: dependencies: '@ai-sdk/anthropic':