diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b413eac..e8de7b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + # The notes are the release commit's summary paragraph (HEAD of the checked-out tag), + # written outside dist/release so they are not uploaded as an asset. + bun scripts/release-notes.ts > "$RUNNER_TEMP/release-notes.md" gh release create "$GITHUB_REF_NAME" dist/release/* \ --verify-tag \ --title "Codeviewer $GITHUB_REF_NAME" \ - --notes "Downloads, checksums and verification steps: https://codeviewer.21no.de/#verify" + --notes-file "$RUNNER_TEMP/release-notes.md" diff --git a/AGENTS.md b/AGENTS.md index 3558755..69f6907 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,5 +114,6 @@ bun run build:local # binaries + the app for this machine (needs X ## Releasing 1. Merge to `main` (squash). 2. Bump `version` in `package.json`, commit `Release X.Y.Z` with a -one-paragraph summary. 3. `git tag vX.Y.Z && git push origin main --tags`. The release workflow +one-paragraph summary (`scripts/release-notes.ts` turns it into the GitHub release notes, which the +update dialog shows; hard-wrapping is fine). 3. `git tag vX.Y.Z && git push origin main --tags`. The release workflow builds, signs `SHA256SUMS` and publishes; the landing page reads the release from GitHub at runtime. diff --git a/DESIGN.md b/DESIGN.md index a9578c9..20e32eb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -917,3 +917,14 @@ Claude Code 2.1.316 through the server reproduced one cause. | (Review) `sendReplay` guessed "trimmed" from the byte count, cutting the first line of replays that were complete. | It uses `ringTrimmed`. | | (Review) A reattach at a new size was nudged on top of its real resize. | Nudge only when the size did not change. | | (Review) Not changed. The 150 ms gap is fixed, so a program stalled for longer sees no change and stays as it was. `ringTrimmed` is never reset, so a TUI started after a trim gets a needless repaint on reattach. Rate-limiting nudges was rejected: each resync resets the screen, so a skipped nudge leaves it blank. | Open: inline TUIs on the normal screen (Claude Code's classic renderer) and programs that ignore SIGWINCH are still not repaired. Keeping screen state on the server, with `Terminal`, would cover them without resizing. | + +## 22. Round 13: release notes in the update dialog + +The "Codeviewer X is available" dialog showed only the verify link instead of what changed. + +| Finding | Fix | +| --- | --- | +| `release.yml` created every release with a fixed `--notes` line, so the release body, which the updater forwards to the dialog, never held the real notes. The summary paragraph lives in the `Release X.Y.Z` commit. | `scripts/release-notes.ts` builds the notes from the release commit (`git log -1 --format=%B`, no shell string): drops the subject and the Co-Authored-By/Signed-off-by trailers, unwraps hard-wrapped paragraphs (lists, headings, quotes, fences and indented blocks stay as written) and appends the verify line. An empty body warns with `::warning::`. The workflow writes the file to `$RUNNER_TEMP` (not uploaded) and passes `--notes-file`; commit text never goes through `${{ }}`. | +| Hard-wrapped paragraphs would break mid-sentence in `.upd-notes` (`white-space: pre-wrap`). | They are unwrapped in the script. | +| The verify line is noise in the dialog (the updater verifies by itself) but useful on the GitHub page. | `RELEASE_FOOTER` in `update.ts` is the single definition; `Updater.check()` strips it before the 4000 cap, so a footer-only body (all older releases) gives empty notes and the dialog shows no box. | +| Fetching the tag's commit at runtime would be a new network request. | Not done (privacy rule). Existing releases keep their old body and are not edited. | diff --git a/README.md b/README.md index 18cc799..031eebb 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,8 @@ updated. Commit that file. Releases fail until the key exists. ### Releasing -1. Bump `version` in `package.json` and commit. +1. Bump `version` in `package.json` and commit `Release X.Y.Z` with a one-paragraph summary. That + paragraph becomes the GitHub release notes and the notes in the update dialog. 2. `git tag v && git push --tags` 3. The release workflow runs the tests, builds every package, signs `SHA256SUMS`, attests provenance and publishes the GitHub release. The landing page picks it up by itself: it diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts new file mode 100644 index 0000000..ef1a20a --- /dev/null +++ b/scripts/release-notes.ts @@ -0,0 +1,49 @@ +// Release notes from the release commit's message: the paragraph after the subject line becomes the +// GitHub release body (and so the update dialog's notes), followed by the verify line. +// +// bun scripts/release-notes.ts [ref] > notes.md (ref defaults to HEAD) +// +// Used by the release workflow. Warns (`::warning::`) on stderr when the commit has no body. +import { RELEASE_FOOTER } from "../src/server/update.ts"; + +const TRAILER = /^(co-authored-by|signed-off-by):/i; +// Lines that mean the paragraph is formatted text: list item, heading, quote, fence or indented. +const BLOCK = /^(\s|[-*+]\s|\d+[.)]\s|#|>|```|~~~)/; + +/** The release notes for a full commit message. */ +export function releaseNotes(message: string): string { + const lines = message.replace(/\r\n?/g, "\n").split("\n").slice(1); // drop the subject + while (lines.length && (!lines[lines.length - 1]!.trim() || TRAILER.test(lines[lines.length - 1]!))) lines.pop(); + + const paras: string[][] = []; + let cur: string[] = []; + let fence = false; + for (const line of lines) { + if (/^(```|~~~)/.test(line)) fence = !fence; + if (!line.trim() && !fence) { + if (cur.length) paras.push(cur); + cur = []; + } else cur.push(line); + } + if (cur.length) paras.push(cur); + + const out = paras.map((p) => (p.some((l) => BLOCK.test(l)) ? p.join("\n") : p.map((l) => l.trim()).join(" "))); + out.push(RELEASE_FOOTER); + return out.join("\n\n"); +} + +/** What the CLI prints for `ref` in the repository at `cwd` (default: the current directory). */ +export function cli(ref: string, cwd?: string): { code: number; out: string; err: string } { + const git = Bun.spawnSync(["git", "log", "-1", "--format=%B", ref, "--"], { stderr: "pipe", cwd }); + if (git.exitCode !== 0) return { code: 1, out: "", err: `release-notes: git log failed for ${ref}: ${git.stderr.toString().trim()}\n` }; + const notes = releaseNotes(git.stdout.toString()); + const err = notes === RELEASE_FOOTER ? `::warning::The commit ${ref} has no summary paragraph: the release notes are only the verify line.\n` : ""; + return { code: 0, out: notes + "\n", err }; +} + +if (import.meta.main) { + const r = cli(process.argv[2] ?? "HEAD"); + process.stderr.write(r.err); + process.stdout.write(r.out); + process.exit(r.code); +} diff --git a/src/server/update.ts b/src/server/update.ts index 0789119..b53930f 100644 --- a/src/server/update.ts +++ b/src/server/update.ts @@ -21,6 +21,8 @@ import updateKeyPem from "../../packaging/update-key.pub" with { type: "text" }; /** Ed25519 public key (PEM / SPKI) whose private half signs every release's SHA256SUMS. */ export const UPDATE_PUBLIC_KEY: string = updateKeyPem as unknown as string; export const REPO = "BackendStack21/codeviewer"; +/** Last paragraph of every release body (scripts/release-notes.ts); the update dialog hides it. */ +export const RELEASE_FOOTER = "Downloads, checksums and verification steps: https://codeviewer.21no.de/#verify"; export type InstallKind = "macos-app" | "linux-app" | "cli" | "source"; @@ -227,7 +229,7 @@ export class Updater { const release: Release = { version, url: rel.html_url ?? `https://github.com/${REPO}/releases`, - notes: (rel.body ?? "").slice(0, 4000), + notes: (rel.body ?? "").replaceAll(RELEASE_FOOTER, "").trim().slice(0, 4000), assets: Object.fromEntries((rel.assets ?? []).map((a) => [a.name, a.browser_download_url])), }; const info: UpdateInfo = { diff --git a/test/client.updates.notes.test.ts b/test/client.updates.notes.test.ts new file mode 100644 index 0000000..6301e08 --- /dev/null +++ b/test/client.updates.notes.test.ts @@ -0,0 +1,26 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { installDom, mockFetch, uninstallDom, type FakeElement } from "./helpers/fakedom.ts"; +import type { UpdateInfo } from "../src/client/api.ts"; + +let U: typeof import("../src/client/updates.ts"); +beforeAll(async () => { + installDom(); + U = await import("../src/client/updates.ts"); +}); +afterAll(() => uninstallDom()); +let restore = () => {}; +afterEach(() => restore()); + +test("the dialog shows no notes box for empty notes and keeps paragraphs for real ones", async () => { + for (const [notes, expected] of [["", null], [" \n ", null], ["One.\n\nTwo.", "One.\n\nTwo."]] as const) { + const info: UpdateInfo = { current: "1.0.0", latest: "1.1.0", available: true, kind: "macos-app", canInstall: true, checkedAt: 1, notes }; + const m = mockFetch(() => Response.json(info)); + restore = m.restore; + const u = new U.Updates(); + await u.load(); + u.open(); + const box = u.el.querySelector(".upd-notes"); + expect(box ? box.textContent : null).toBe(expected); + restore(); + } +}); diff --git a/test/release-notes.cli.test.ts b/test/release-notes.cli.test.ts new file mode 100644 index 0000000..f06935b --- /dev/null +++ b/test/release-notes.cli.test.ts @@ -0,0 +1,87 @@ +// The release workflow runs scripts/release-notes.ts against a real repository. +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { cli } from "../scripts/release-notes.ts"; +import { RELEASE_FOOTER } from "../src/server/update.ts"; + +const SCRIPT = path.resolve(import.meta.dir, "../scripts/release-notes.ts"); +const env = { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "T", + GIT_AUTHOR_EMAIL: "t@example.test", + GIT_COMMITTER_NAME: "T", + GIT_COMMITTER_EMAIL: "t@example.test", +}; +let dir = ""; +const git = (...args: string[]) => { + const r = Bun.spawnSync(["git", "-c", "commit.gpgsign=false", "-c", "user.name=T", "-c", "user.email=t@example.test", ...args], { cwd: dir, env, stderr: "pipe" }); + if (r.exitCode !== 0) throw new Error(r.stderr.toString()); +}; +const run = (...args: string[]) => { + const r = Bun.spawnSync(["bun", SCRIPT, ...args], { cwd: dir, env, stdout: "pipe", stderr: "pipe" }); + return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }; +}; + +beforeAll(async () => { + dir = await realpath(await mkdtemp(path.join(tmpdir(), "cv-relnotes-"))); + git("init", "-q"); + git("commit", "-q", "--allow-empty", "-m", "Release 1.0.0\n\nFirst summary\nwrapped over two lines.\n\nCo-Authored-By: A "); + git("tag", "v1.0.0"); + git("commit", "-q", "--allow-empty", "-m", "Release 1.1.0\n\nSecond summary.\n\nCo-Authored-By: A "); + git("tag", "v1.1.0"); + git("commit", "-q", "--allow-empty", "-m", "Release 1.2.0\n\nCo-Authored-By: A "); +}); +afterAll(() => rm(dir, { recursive: true, force: true })); + +describe("release-notes CLI (real process)", () => { + test("reads HEAD by default: empty body gives the verify line and a warning", () => { + const r = run(); + expect(r.code).toBe(0); + expect(r.out).toBe(RELEASE_FOOTER + "\n"); + expect(r.err).toContain("::warning::"); + }); + + test("an older ref is read when given", () => { + const r = run("v1.1.0"); + expect(r.code).toBe(0); + expect(r.out).toBe(`Second summary.\n\n${RELEASE_FOOTER}\n`); + expect(r.out).not.toContain("Release 1.1.0"); + expect(r.out).not.toContain("Co-Authored-By"); + expect(r.err).toBe(""); + expect(run("v1.0.0").out).toBe(`First summary wrapped over two lines.\n\n${RELEASE_FOOTER}\n`); + }); + + test("HEAD default with a summary paragraph", () => { + git("commit", "-q", "--allow-empty", "-m", "Release 1.3.0\n\nThird summary\nwrapped.\n\nCo-Authored-By: A "); + const r = run(); + expect(r.code).toBe(0); + expect(r.out).toBe(`Third summary wrapped.\n\n${RELEASE_FOOTER}\n`); + expect(r.err).toBe(""); + }); + + test("a missing ref fails and prints no notes", () => { + const r = run("v9.9.9"); + expect(r.code).not.toBe(0); + expect(r.out).toBe(""); + expect(r.err).toContain("release-notes:"); + }); +}); + +describe("cli()", () => { + test("HEAD default content, older ref, empty body warning, missing ref", () => { + const old = cli("v1.1.0", dir); + expect(old).toEqual({ code: 0, out: `Second summary.\n\n${RELEASE_FOOTER}\n`, err: "" }); + const head = cli("HEAD~1", dir); + expect(head.out).toBe(RELEASE_FOOTER + "\n"); + expect(head.err).toStartWith("::warning::"); + const bad = cli("v9.9.9", dir); + expect(bad.code).toBe(1); + expect(bad.out).toBe(""); + expect(bad.err).toStartWith("release-notes: git log failed for v9.9.9"); + }); +}); diff --git a/test/release-notes.test.ts b/test/release-notes.test.ts new file mode 100644 index 0000000..ca70d6e --- /dev/null +++ b/test/release-notes.test.ts @@ -0,0 +1,82 @@ +// The release commit's paragraph becomes the GitHub release notes and the update dialog's notes. +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { releaseNotes } from "../scripts/release-notes.ts"; +import { RELEASE_FOOTER, Updater, type Install } from "../src/server/update.ts"; + +const TRAILER = "Co-Authored-By: Claude "; + +describe("releaseNotes", () => { + test("drops the subject and trailer, unwraps a hard-wrapped paragraph, appends the footer", () => { + const msg = [ + "Release 1.10.3", + "", + "Long agent sessions stay cheap: the terminal copies ASCII runs in one", + "step, skips rows that did not change and keeps the replay ring small,", + "so a busy session no longer slows the page down.", + "", + TRAILER, + "", + ].join("\n"); + expect(releaseNotes(msg)).toBe( + "Long agent sessions stay cheap: the terminal copies ASCII runs in one step, skips rows that did not change and keeps the replay ring small, so a busy session no longer slows the page down.\n\n" + RELEASE_FOOTER, + ); + }); + + test("a single-line body", () => { + expect(releaseNotes(`Release 1.10.4\n\nFull-screen programs repaint after a replay.\n\n${TRAILER}\n`)).toBe( + `Full-screen programs repaint after a replay.\n\n${RELEASE_FOOTER}`, + ); + }); + + test("an empty body yields only the footer", () => { + expect(releaseNotes(`Release 1.10.5\n\n${TRAILER}\n`)).toBe(RELEASE_FOOTER); + expect(releaseNotes("Release 1.10.5\n")).toBe(RELEASE_FOOTER); + }); + + test("keeps paragraphs apart, each unwrapped, and drops Signed-off-by too", () => { + const msg = `Release 2.0.0\n\nFirst part\nwraps here.\n\nSecond part\nalso wraps.\n\nSigned-off-by: A \n${TRAILER}\n`; + expect(releaseNotes(msg)).toBe(`First part wraps here.\n\nSecond part also wraps.\n\n${RELEASE_FOOTER}`); + }); + + test("leaves list blocks alone", () => { + const msg = `Release 2.0.0\n\nChanges that matter\nto you:\n\n- one\n- two that is\n continued\n\n${TRAILER}\n`; + expect(releaseNotes(msg)).toBe(`Changes that matter to you:\n\n- one\n- two that is\n continued\n\n${RELEASE_FOOTER}`); + }); +}); + +describe("release workflow", () => { + test("generates notes with the script and publishes them from a file", async () => { + const yml = await readFile(path.join(import.meta.dir, "../.github/workflows/release.yml"), "utf8"); + expect(yml).toContain("scripts/release-notes.ts"); + expect(yml).toContain("--notes-file"); + expect(yml).not.toMatch(/--notes\s/); + expect(yml).not.toContain("--notes \""); + expect(yml).not.toMatch(/run:[^\n]*\$\{\{[^}]*(commit|message|head)/i); + }); +}); + +describe("Updater.check release notes", () => { + function updater(body: string) { + const fetchFn = (async () => + Response.json({ tag_name: "v1.1.0", html_url: "https://example.test/release", body, assets: [] })) as unknown as typeof fetch; + const install: Install = { kind: "cli", target: "/nonexistent/codeviewer", asset: (v) => `codeviewer-${v}` }; + return new Updater({ version: "1.0.0", fetch: fetchFn, assetHosts: ["example.test"], publicKey: "", install }); + } + + test("strips the footer and keeps a multi-paragraph body", async () => { + const info = await updater(`First.\n\nSecond.\n\n${RELEASE_FOOTER}`).check(true); + expect(info.notes).toBe("First.\n\nSecond."); + expect(info.notes).not.toContain("codeviewer.21no.de"); + }); + + test("a footer-only body gives empty notes", async () => { + expect((await updater(RELEASE_FOOTER).check(true)).notes?.trim()).toBe(""); + expect((await updater(`${RELEASE_FOOTER}\n`).check(true)).notes?.trim()).toBe(""); + }); + + test("old releases with only the fixed line show no notes", async () => { + expect((await updater(RELEASE_FOOTER).check(true)).notes ?? "").toBe(""); + }); +});