From da16dce5efdd2baf175e82eff853acafe3037eb5 Mon Sep 17 00:00:00 2001 From: Mohamed Farahat Date: Sun, 9 Aug 2026 21:09:43 +0000 Subject: [PATCH] feat(release): publish the version's changelog section as the release notes Release tags are lightweight, so the workflow's `gh release create --notes-from-tag` had nothing to read beyond the "Bump to vX.Y.Z" commit subject: every GitHub Release shipped with an effectively empty description, and release watchers saw no notes (oblien/openship#192). scripts/changelog-notes.ts (zero-dep, node:-only) extracts the version's level-2 section from CHANGELOG.md: exact version match (0.6.1 never matches 0.6.10), tolerant of `v`/bracketed/dated headings, bounded by the next version heading. Missing section falls back to a changelog link; a prerelease with no own section borrows the base version's notes with a prerelease preamble; bodies are cut at 120k chars on a line boundary. Every path returns a body: notes can never fail a release. The publish job writes the notes to a file and passes --notes-file (never shell-interpolated: changelog prose is full of backticks), and on re-runs backfills only releases whose body is empty, so hand-edited release pages are never overwritten. The docker-only path creates no release and is untouched. release.ts now surfaces the resolved notes state in its pre-flight summary so a missing section is visible before the tag is pushed. Tests (bun test, wired into the root test script as test:scripts) cover section bounding, version prefix collisions, fallback and prerelease borrowing, truncation, and extraction against the real CHANGELOG.md. --- .github/workflows/release.yml | 42 ++++++++- package.json | 3 +- scripts/changelog-notes.test.ts | 154 ++++++++++++++++++++++++++++++++ scripts/changelog-notes.ts | 134 +++++++++++++++++++++++++++ scripts/release.ts | 23 +++++ 5 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 scripts/changelog-notes.test.ts create mode 100644 scripts/changelog-notes.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0099dfaff..686c096fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -704,9 +704,41 @@ jobs: path: dist merge-multiple: true + - name: Read bun version + id: bun-version + run: echo "version=$(cat .bun-version)" >> "$GITHUB_OUTPUT" + + # Only to run scripts/changelog-notes.ts (node:-only, zero deps) — this + # job deliberately does no `bun install`. + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ steps.bun-version.outputs.version }} + + # The release body is this version's CHANGELOG.md section, written to a + # FILE (never interpolated into the shell — changelog prose is full of + # backticks and quotes) and passed to `gh --notes-file`. The extractor + # always emits a body, and this step is non-fatal on top of that: missing + # notes must never fail a release. + - name: Build release notes from CHANGELOG.md + id: notes + run: | + set -uo pipefail + TAG="${GITHUB_REF_NAME}" + NOTES_FILE="${RUNNER_TEMP}/release-notes.md" + if ! bun scripts/changelog-notes.ts "${TAG}" > "${NOTES_FILE}"; then + echo "::warning::changelog extraction failed — publishing a generic body." + printf 'See [CHANGELOG.md](https://github.com/%s/blob/%s/CHANGELOG.md).\n' \ + "${GITHUB_REPOSITORY}" "${TAG}" > "${NOTES_FILE}" + fi + echo "file=${NOTES_FILE}" >> "$GITHUB_OUTPUT" + echo "▸ release notes (${TAG}):" + head -c 500 "${NOTES_FILE}" + - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NOTES_FILE: ${{ steps.notes.outputs.file }} run: | set -euo pipefail TAG="${GITHUB_REF_NAME}" @@ -731,10 +763,18 @@ jobs: if gh release view "${TAG}" >/dev/null 2>&1; then echo "::notice::Release ${TAG} exists — re-uploading core assets (--clobber)." gh release upload "${TAG}" "${CORE_ASSETS[@]}" --clobber + # Backfill notes ONLY when the existing release has none (a release + # cut before this step existed, or a re-run of one that published an + # empty body). A body that's already there may have been hand-edited + # on the release page — never overwrite it. + if [[ -z "$(gh release view "${TAG}" --json body --jq '.body' | tr -d '[:space:]')" ]]; then + gh release edit "${TAG}" --notes-file "${NOTES_FILE}" + echo "::notice::Backfilled empty release notes for ${TAG}." + fi else gh release create "${TAG}" \ --title "Openship ${TAG}" \ - --notes-from-tag \ + --notes-file "${NOTES_FILE}" \ ${PRERELEASE_FLAG} \ "${CORE_ASSETS[@]}" fi diff --git a/package.json b/package.json index e1f3ae842..ebae2d05a 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "dev:turbo": "turbo run dev", "clean": "ps -A -o pid,command 2>/dev/null | grep -E 'src/index\\.ts|next/dist/bin/next' | grep -v grep | awk '{print $1}' | xargs -I {} kill -9 {} 2>/dev/null; for p in 4000 4100 3001 3002; do pid=$(lsof -ti tcp:$p 2>/dev/null); [ -n \"$pid\" ] && kill -9 $pid 2>/dev/null; done; echo 'cleaned'", "lint": "turbo run lint --filter=!@repo/email", - "test": "turbo run test --filter=!@repo/email --concurrency=1", + "test": "turbo run test --filter=!@repo/email --concurrency=1 && bun run test:scripts", + "test:scripts": "bun test ./scripts/", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "db:generate": "turbo run db:generate --filter=@repo/db", "db:push": "turbo run db:push --filter=@repo/db", diff --git a/scripts/changelog-notes.test.ts b/scripts/changelog-notes.test.ts new file mode 100644 index 000000000..0399b339e --- /dev/null +++ b/scripts/changelog-notes.test.ts @@ -0,0 +1,154 @@ +/** + * `bun run test:scripts` (→ `bun test ./scripts/`), which the root `test` + * script — and therefore CI — chains after the turbo/vitest run. scripts/ is + * outside every workspace, so there is no vitest project to host this; the + * scripts are already `#!/usr/bin/env bun`, so bun's own runner is the + * zero-config fit. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + MAX_BODY_LENGTH, + buildReleaseNotes, + extractChangelogSection, + truncateBody, +} from "./changelog-notes"; + +const SAMPLE = `# Changelog + +All notable changes to Openship. + +## 0.6.1 + +Headline paragraph. + +### Networking + +- Did a thing with \`backticks\` and "quotes". + +## 0.4.9 + +### Rollback + +- Older entry. + +## 0.4.7 +`; + +describe("extractChangelogSection", () => { + test("returns the section body up to the next version heading", () => { + const body = extractChangelogSection(SAMPLE, "0.6.1"); + expect(body).toBe( + [ + "Headline paragraph.", + "", + "### Networking", + "", + '- Did a thing with `backticks` and "quotes".', + ].join("\n"), + ); + }); + + test("accepts a v-prefixed tag and stops before the following version", () => { + const body = extractChangelogSection(SAMPLE, "v0.4.9"); + expect(body).toBe(["### Rollback", "", "- Older entry."].join("\n")); + expect(body).not.toContain("0.4.7"); + }); + + test("returns null for a missing version and for an empty trailing section", () => { + expect(extractChangelogSection(SAMPLE, "9.9.9")).toBeNull(); + expect(extractChangelogSection(SAMPLE, "0.4.7")).toBeNull(); + }); + + test("does not match a prefix of another version", () => { + expect(extractChangelogSection("## 0.6.10\n\nten\n", "0.6.1")).toBeNull(); + }); + + test("tolerates dated and bracketed headings", () => { + expect(extractChangelogSection("## [1.2.3] - 2026-01-02\n\nnotes\n", "1.2.3")).toBe("notes"); + expect(extractChangelogSection("## v1.2.3 — 2026-01-02\n\nnotes\n", "1.2.3")).toBe("notes"); + }); +}); + +describe("buildReleaseNotes", () => { + test("uses the version's own section", () => { + expect(buildReleaseNotes(SAMPLE, "v0.6.1")).toContain("Headline paragraph."); + }); + + test("falls back to a CHANGELOG pointer when the version has no entry", () => { + const notes = buildReleaseNotes(SAMPLE, "v9.9.9", { repo: "oblien/openship" }); + expect(notes).toContain("No changelog entry for 9.9.9"); + expect(notes).toContain("https://github.com/oblien/openship/blob/v9.9.9/CHANGELOG.md"); + }); + + test("never throws on an empty or malformed changelog", () => { + expect(buildReleaseNotes("", "v1.0.0")).toContain("No changelog entry for 1.0.0"); + expect(buildReleaseNotes("###### not a version\n#\n##\n", "v1.0.0")).toContain("CHANGELOG.md"); + }); + + test("a prerelease borrows its base version's section, labelled", () => { + const notes = buildReleaseNotes(SAMPLE, "v0.6.1-rc.2"); + expect(notes).toStartWith("_Prerelease of 0.6.1 —"); + expect(notes).toContain("Headline paragraph."); + }); + + test("a prerelease with its own section prefers it", () => { + const changelog = "## 0.7.0-rc.1\n\nrc notes\n\n## 0.7.0\n\nstable notes\n"; + expect(buildReleaseNotes(changelog, "v0.7.0-rc.1")).toBe("rc notes"); + }); + + test("stays under GitHub's release-body limit", () => { + const huge = `## 1.0.0\n\n${"- a very long bullet line\n".repeat(20000)}`; + const notes = buildReleaseNotes(huge, "v1.0.0"); + expect(huge.length).toBeGreaterThan(MAX_BODY_LENGTH); + expect(notes.length).toBeLessThanOrEqual(MAX_BODY_LENGTH); + expect(notes).toContain("…truncated."); + }); + + test("truncateBody cuts on a line boundary", () => { + const out = truncateBody("aaa\nbbb\nccc\n", "https://example.test/CHANGELOG.md", 80); + expect(out.split("\n")[0]).toBe("aaa"); + }); +}); + +describe("the real CHANGELOG.md", () => { + const changelog = readFileSync( + join(dirname(dirname(fileURLToPath(import.meta.url))), "CHANGELOG.md"), + "utf8", + ); + + test("every documented version resolves to a non-empty section", () => { + const versions = [...changelog.matchAll(/^##\s+v?(\d+\.\d+\.\d+[^\s]*)/gm)].map((m) => m[1]); + expect(versions.length).toBeGreaterThan(0); + for (const v of versions) { + const body = extractChangelogSection(changelog, v); + expect(body, `section for ${v}`).toBeTruthy(); + expect(body).not.toContain(`## ${v}`); + } + }); + + test("the current package version yields a valid body either way", () => { + // Not every release has a section (0.5.0, 0.5.5, 0.6.0 and 0.6.5 shipped + // without one), so the honest contract is: a real section when one exists, + // the changelog-link fallback when it doesn't, never an empty body. + const root = dirname(dirname(fileURLToPath(import.meta.url))); + const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version as string; + const notes = buildReleaseNotes(changelog, `v${version}`); + const hasSection = extractChangelogSection(changelog, version) !== null; + if (hasSection) expect(notes).not.toContain("No changelog entry"); + else expect(notes).toContain("CHANGELOG.md"); + expect(notes.length).toBeGreaterThan(0); + expect(notes.length).toBeLessThanOrEqual(MAX_BODY_LENGTH); + }); + + test("a version with a real section gets its notes, and they fit the limit", () => { + // 0.6.1 is pinned as a version known to carry a section, so this cannot + // rot when a future release ships without one. + const notes = buildReleaseNotes(changelog, "v0.6.1"); + expect(notes).not.toContain("No changelog entry"); + expect(notes.length).toBeLessThanOrEqual(MAX_BODY_LENGTH); + }); +}); diff --git a/scripts/changelog-notes.ts b/scripts/changelog-notes.ts new file mode 100644 index 000000000..a6ff07492 --- /dev/null +++ b/scripts/changelog-notes.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env bun +/** + * Build the GitHub Release body for a tag from the root CHANGELOG.md. + * + * Usage: + * bun scripts/changelog-notes.ts v0.6.1 # → release body on stdout + * bun scripts/changelog-notes.ts 0.6.1 --changelog path/to/CHANGELOG.md + * + * `.github/workflows/release.yml` runs this in the `publish` job and hands the + * output to `gh release create --notes-file`, so the release description + * carries the version's changelog section instead of the tag message (the + * release tags are LIGHTWEIGHT, so the old `--notes-from-tag` had nothing but + * the "Bump to vX.Y.Z" commit subject to work with). + * + * This must NEVER fail a release: every path returns a body, missing or + * malformed changelog included, and the file imports nothing outside node: so + * it runs on a bare `bun` with no `bun install`. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** GitHub rejects a release body over 125,000 chars — stay clear of the edge. */ +export const MAX_BODY_LENGTH = 120_000; + +/** A level-2 heading that names a version: `## 0.6.1`, `## v0.6.1 — 2026-01-01`, + * `## [0.6.1] - 2026-01-01`. Levels 3+ (`### Fixes`) are section content. */ +const VERSION_HEADING = /^##\s+v?\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)*)\]?/; + +/** Strip a leading `v` and surrounding whitespace: `v0.6.1` → `0.6.1`. */ +export function normalizeVersion(input: string): string { + return input.trim().replace(/^v/i, ""); +} + +/** + * The body of `version`'s section: everything after its `## ` heading + * up to the next version heading (or EOF), trimmed of blank edges. Returns null + * when the version has no section, or when its section is empty. + */ +export function extractChangelogSection(changelog: string, version: string): string | null { + const want = normalizeVersion(version); + if (!want) return null; + + const lines = changelog.split(/\r?\n/); + let start = -1; + let end = lines.length; + + for (let i = 0; i < lines.length; i++) { + const m = VERSION_HEADING.exec(lines[i]); + if (!m) continue; + if (start === -1) { + if (m[1] === want) start = i + 1; + } else { + // The next version heading closes the section (newest-at-top ordering is + // irrelevant here — we bound on headings, not on order). + end = i; + break; + } + } + + if (start === -1) return null; + const body = lines.slice(start, end).join("\n").trim(); + return body.length > 0 ? body : null; +} + +/** Cut `body` to `max` chars on a line boundary, with a pointer to the rest. */ +export function truncateBody(body: string, changelogUrl: string, max = MAX_BODY_LENGTH): string { + if (body.length <= max) return body; + const notice = `\n\n…truncated. Read the full entry in [CHANGELOG.md](${changelogUrl}).`; + const room = max - notice.length; + const head = body.slice(0, room); + const lastBreak = head.lastIndexOf("\n"); + return (lastBreak > room / 2 ? head.slice(0, lastBreak) : head).trimEnd() + notice; +} + +/** + * The release body for `tag`. Order of preference: + * 1. the tag's own changelog section; + * 2. for a prerelease (`v0.7.0-rc.1`), the base version's section if the + * changelog is already written for the upcoming release, clearly labelled; + * 3. a generic body pointing at CHANGELOG.md — a version with no entry + * (several shipped tags have none) must still publish. + */ +export function buildReleaseNotes( + changelog: string, + tag: string, + opts: { repo?: string; max?: number } = {}, +): string { + const repo = opts.repo || "oblien/openship"; + const version = normalizeVersion(tag); + const base = version.split("-")[0]; + const changelogUrl = `https://github.com/${repo}/blob/${tag}/CHANGELOG.md`; + + let body = extractChangelogSection(changelog, version); + if (!body && base !== version) { + const upcoming = extractChangelogSection(changelog, base); + if (upcoming) { + body = `_Prerelease of ${base} — notes for the upcoming release:_\n\n${upcoming}`; + } + } + if (!body) { + body = + `No changelog entry for ${version} yet — see [CHANGELOG.md](${changelogUrl}) ` + + `for the full history.`; + } + + return truncateBody(body, changelogUrl, opts.max ?? MAX_BODY_LENGTH); +} + +/* ─── CLI ───────────────────────────────────────────────────────────── */ + +if (import.meta.main) { + const args = process.argv.slice(2); + const tag = args.find((a) => !a.startsWith("--")); + if (!tag) { + console.error("Usage: bun scripts/changelog-notes.ts [--changelog ]"); + process.exit(1); + } + const root = dirname(dirname(fileURLToPath(import.meta.url))); + const path = + args.find((a) => a.startsWith("--changelog="))?.slice("--changelog=".length) ?? + join(root, "CHANGELOG.md"); + + let changelog = ""; + try { + changelog = readFileSync(path, "utf8"); + } catch { + // An unreadable CHANGELOG.md is not a reason to fail the release — fall + // through to the generic body. + console.error(`::warning::could not read ${path} — publishing generic release notes`); + } + process.stdout.write(buildReleaseNotes(changelog, tag, { repo: process.env.GITHUB_REPOSITORY })); +} diff --git a/scripts/release.ts b/scripts/release.ts index 2875bc65b..2ecf09285 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -42,6 +42,9 @@ * The tag-push triggers .github/workflows/release.yml which builds both * dist tarballs + SHA-256 sidecars and publishes a GitHub Release. * Tags containing `-` (rc.N, beta.N) become prereleases automatically. + * The release DESCRIPTION is this version's CHANGELOG.md section (see + * scripts/changelog-notes.ts) — write it before you cut the tag; a version with + * no section still releases, it just links to the changelog instead. */ import { spawnSync } from "node:child_process"; @@ -49,6 +52,7 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { buildWizardArgs, type WizardAnswers } from "./release-args"; +import { extractChangelogSection } from "./changelog-notes"; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const ROOT_PKG = join(ROOT, "package.json"); @@ -214,6 +218,7 @@ log(`Announcement: ${publish ? `yes (${critical ? "critical" : "re if (publish) { log(`Audience: ${modes ? modes.join(", ") : "all installs (desktop, selfhosted, cloud)"}`); } +log(`Release notes: ${changelogNotesStatus(next)}`); log(``); if (dryRun) { @@ -379,6 +384,24 @@ function usageAndExit(code = 1): never { process.exit(code); } +/** What the GitHub release body will say, so a missing CHANGELOG.md section is + * visible BEFORE the tag is pushed. Purely informational — a version with no + * section still releases (the workflow falls back to a changelog link). */ +function changelogNotesStatus(version: string): string { + try { + const md = readFileSync(join(ROOT, "CHANGELOG.md"), "utf8"); + const section = extractChangelogSection(md, version); + if (section) return `CHANGELOG.md § ${version} (${section.split("\n").length} lines)`; + const base = version.split("-")[0]; + if (base !== version && extractChangelogSection(md, base)) { + return `CHANGELOG.md § ${base} (base version — this is a prerelease)`; + } + } catch { + // fall through + } + return `⚠ no CHANGELOG.md section for ${version} — the release will link to the changelog instead`; +} + /** True if the tag already exists locally or on origin. */ function tagExists(t: string): boolean { const local = git("tag", "--list", t, { capture: true }).trim();