Skip to content
Open
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
42 changes: 41 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
154 changes: 154 additions & 0 deletions scripts/changelog-notes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
134 changes: 134 additions & 0 deletions scripts/changelog-notes.ts
Original file line number Diff line number Diff line change
@@ -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 `## <version>` 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 <tag> [--changelog <path>]");
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 }));
}
Loading