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
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 11 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version> && 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
Expand Down
49 changes: 49 additions & 0 deletions scripts/release-notes.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 3 additions & 1 deletion src/server/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 = {
Expand Down
26 changes: 26 additions & 0 deletions test/client.updates.notes.test.ts
Original file line number Diff line number Diff line change
@@ -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<FakeElement>(".upd-notes");
expect(box ? box.textContent : null).toBe(expected);
restore();
}
});
87 changes: 87 additions & 0 deletions test/release-notes.cli.test.ts
Original file line number Diff line number Diff line change
@@ -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 <a@example.test>");
git("tag", "v1.0.0");
git("commit", "-q", "--allow-empty", "-m", "Release 1.1.0\n\nSecond summary.\n\nCo-Authored-By: A <a@example.test>");
git("tag", "v1.1.0");
git("commit", "-q", "--allow-empty", "-m", "Release 1.2.0\n\nCo-Authored-By: A <a@example.test>");
});
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 <a@example.test>");
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");
});
});
82 changes: 82 additions & 0 deletions test/release-notes.test.ts
Original file line number Diff line number Diff line change
@@ -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 <noreply@anthropic.com>";

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 <a@x.y>\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("");
});
});
Loading