From ae0e62767494cc9ac3b386bb349e00c30400a08b Mon Sep 17 00:00:00 2001 From: pdelfan Date: Fri, 16 Oct 2026 16:47:22 -0700 Subject: [PATCH] The app has one version, the root package.json's, now 0.1.1 to match the latest release, and CHANGELOG.md records what each release changed, with what has landed since 0.1.1 under Unreleased. The workspace packages under apps/ and packages/ drop the 0.1.0 they never kept up, while plugins keep their own versions, which the admin's plugin list shows. pnpm release cuts a release: it dates Unreleased, bumps the version, commits, makes an annotated tag, pushes, and publishes the GitHub Release with that version's section as its notes, refusing unless main is clean and current and Unreleased holds something. docs/releasing.md has the versioning rules and the steps, and CLAUDE.md says to add a changelog line with each change someone would notice. --- CHANGELOG.md | 38 ++++++++++++ CLAUDE.md | 1 + apps/server/package.json | 1 - apps/web/package.json | 1 - docs/releasing.md | 36 ++++++++++++ package.json | 5 +- packages/lexicons/package.json | 1 - packages/plugin-api/package.json | 1 - packages/release/package.json | 9 +++ packages/release/src/release-cli.ts | 79 +++++++++++++++++++++++++ packages/release/src/release.ts | 45 +++++++++++++++ packages/release/tests/release.test.ts | 80 ++++++++++++++++++++++++++ packages/release/tsconfig.json | 4 ++ pnpm-lock.yaml | 2 + 14 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/releasing.md create mode 100644 packages/release/package.json create mode 100644 packages/release/src/release-cli.ts create mode 100644 packages/release/src/release.ts create mode 100644 packages/release/tests/release.test.ts create mode 100644 packages/release/tsconfig.json 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': -- knotmirror 0.0.1