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
5 changes: 5 additions & 0 deletions .changeset/update-release-notes-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Link the matching hunk.dev release notes after `hunk update` installs a version, reports one as already current, or checks for a newer one.
74 changes: 74 additions & 0 deletions packages/hunk/src/core/install/selfUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { join } from "node:path";
import { UNKNOWN_CLI_VERSION } from "../run/version";
import type { InstallSource } from "./installSource";
import {
parseUpdateMethod,
Expand Down Expand Up @@ -371,4 +372,77 @@ describe("hunk update", () => {
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Hunk was installed with mise.");
});

test("links the release notes for the version it installed", async () => {
const result = await runUpdate({
installSource: "npm",
installedVersion: "1.0.0",
latestVersion: "1.1.0",
});

expect(result.stdout).toContain("Updated hunk to 1.1.0.");
expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.1/#v1-1-0");
});

test("links the release notes for a version that is already current", async () => {
const result = await runUpdate({
installSource: "npm",
installedVersion: "1.1.0",
latestVersion: "1.1.0",
});

expect(result.stdout).toContain("hunk 1.1.0 is already up to date.");
expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.1/#v1-1-0");
});

test("links the release notes for a prerelease it installed", async () => {
const result = await runUpdate({
installSource: "npm",
installedVersion: "1.0.0",
latestVersion: "1.1.0-beta.0",
input: { version: "1.1.0-beta.0" },
});

expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.1/#v1-1-0-beta-0");
});

test("links the release notes for an explicit downgrade", async () => {
const result = await runUpdate({
installSource: "npm",
installedVersion: "1.1.0",
latestVersion: "1.1.0",
input: { version: "1.0.5" },
});

expect(result.stdout).toContain("Updated hunk to 1.0.5.");
expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.0/#v1-0-5");
});

test("links the latest available release notes for --check", async () => {
const result = await runUpdate({
installSource: "npm",
installedVersion: "1.0.0",
latestVersion: "1.1.0",
input: { check: true },
});

expect(result.stdout).toContain("An update is available.");
expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.1/#v1-1-0");
});

test("omits the release notes link for a version with no changelog page", async () => {
// `--check` reports the installed version too, and a local build reports `0.0.0-unknown`.
// Only the latest release gets a link; printing a plausible-looking URL to a 404 for the
// unparseable one would be worse than printing nothing.
const result = await runUpdate({
installSource: "npm",
installedVersion: UNKNOWN_CLI_VERSION,
latestVersion: "1.1.0",
input: { check: true },
});

expect(result.stdout).toContain(UNKNOWN_CLI_VERSION);
expect(result.stdout).toContain("Release notes: https://hunk.dev/changelog/1.1/#v1-1-0");
expect(result.stdout).not.toContain(`#v0-0-0-unknown`);
});
});
20 changes: 20 additions & 0 deletions packages/hunk/src/core/install/selfUpdate.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { HunkUserError } from "../run/errors";
import { detectInstallSource, detectNpmClient, type InstallSource } from "./installSource";
import { fetchChannelVersions, type FetchImpl } from "./latestRelease";
import { releaseNotesUrl } from "../run/releaseNotes";
import { isComparableVersion, isNewerVersion, resolveCliVersion } from "../run/version";

/**
Expand Down Expand Up @@ -42,6 +43,20 @@ function listUpdateMethods() {
return `${quoted.slice(0, -1).join(", ")}, and ${quoted.at(-1)}`;
}

/**
* Print the release-notes link for one version, when that version has a changelog page.
*
* A plain URL rather than an OSC-8 hyperlink: this output is piped and captured in CI as often as
* it is read in a terminal, and an escape-wrapped link is invisible to everything downstream.
* Silent when the version has no page, so a dev build prints its normal output and no dead link.
*/
function reportReleaseNotes(io: SelfUpdateIo, version: string) {
const url = releaseNotesUrl(version);
if (url) {
io.stdout(`Release notes: ${url}\n`);
}
}

export interface SelfUpdateInput {
/** Version to install; the channel's newest release when omitted. */
version?: string;
Expand Down Expand Up @@ -329,6 +344,9 @@ export async function runSelfUpdateCommand(
? "An update is available. Run `hunk update` to install it.\n"
: "Hunk is up to date.\n",
);
// Link whichever version this run reported on: an explicit `--version` request is what the
// user asked about, otherwise the channel's latest is what the lines above describe.
reportReleaseNotes(io, input.version ?? latestVersion);
return 0;
}

Expand All @@ -345,6 +363,7 @@ export async function runSelfUpdateCommand(
: !isComparableVersion(installedVersion) || !isNewerVersion(installedVersion, targetVersion);
if (alreadyCurrent) {
io.stdout(`hunk ${installedVersion} is already up to date.\n`);
reportReleaseNotes(io, installedVersion);
return 0;
}

Expand Down Expand Up @@ -375,5 +394,6 @@ export async function runSelfUpdateCommand(
}

io.stdout(`Updated hunk to ${targetVersion}.\n`);
reportReleaseNotes(io, targetVersion);
return 0;
}
77 changes: 77 additions & 0 deletions packages/hunk/src/core/run/releaseNotes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { releaseNotesUrl } from "./releaseNotes";
import { UNKNOWN_CLI_VERSION } from "./version";

const CHANGELOG_CONTENT_DIR = join(
import.meta.dir,
"..",
"..",
"..",
"..",
"..",
"website",
"src",
"content",
"docs",
"changelog",
);

describe("releaseNotesUrl", () => {
test("addresses one stable release on its series page", () => {
expect(releaseNotesUrl("0.21.0")).toBe("https://hunk.dev/changelog/0.21/#v0-21-0");
expect(releaseNotesUrl("0.18.2")).toBe("https://hunk.dev/changelog/0.18/#v0-18-2");
});

test("keeps a prerelease on the series page of the release it leads to", () => {
expect(releaseNotesUrl("0.21.0-beta.0")).toBe(
"https://hunk.dev/changelog/0.21/#v0-21-0-beta-0",
);
});

test("carries a major version into the series path", () => {
expect(releaseNotesUrl("1.4.0")).toBe("https://hunk.dev/changelog/1.4/#v1-4-0");
});

test("returns nothing for a version with no changelog page", () => {
// A link is only useful if it resolves. Anything that is not a normalized semver — a dev
// build, a distro-patched string, the unknown-version sentinel — has no page to point at.
for (const version of [UNKNOWN_CLI_VERSION, "", "dev", "0.21", "v0.21.0", "0.21.0+local"]) {
expect(releaseNotesUrl(version)).toBeUndefined();
}
});

test("resolves against the changelog pages the website actually publishes", () => {
// The generated pages own the real addressing: each carries its series slug in frontmatter and
// one anchor per release. Reading them keeps this URL builder honest even though the generator
// lives in build tooling the shipped CLI cannot import.
const anchorPattern = /id="(v[0-9][0-9a-z-]*)"/g;
let checked = 0;

for (const entry of readdirSync(CHANGELOG_CONTENT_DIR)) {
if (!entry.endsWith(".md")) continue;

const page = readFileSync(join(CHANGELOG_CONTENT_DIR, entry), "utf8");
const slug = page.match(/^slug:\s*(\S+)$/m)?.[1];
if (!slug) continue;

for (const [, anchor] of page.matchAll(anchorPattern)) {
if (!anchor) continue;

// Recover the version this anchor addresses, then confirm the builder reproduces both
// halves of the published URL from that version alone.
const version = anchor
.slice(1)
.replace(/-(?=(?:beta|alpha|rc)\b)/, "@")
.replaceAll("-", ".")
.replace("@", "-");
expect(releaseNotesUrl(version)).toBe(`https://hunk.dev/${slug}/#${anchor}`);
checked += 1;
}
}

// Guard the guard: a glob that silently matched nothing would prove nothing.
expect(checked).toBeGreaterThan(20);
});
});
49 changes: 49 additions & 0 deletions packages/hunk/src/core/run/releaseNotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Builds the hunk.dev changelog URL for one released version.
*
* The changelog groups releases into per-minor series pages and marks each release with a stable
* anchor, so an exact version addresses one entry rather than the top of a page. Both halves of
* that address are decided by `scripts/generate/generate-changelog.ts`, which renders the pages:
* the series slug from `minorSeriesOf` and the anchor from `versionAnchor`. The rules are restated
* here rather than imported because `scripts/` is build tooling the shipped CLI cannot depend on;
* `releaseNotes.test.ts` pins them against the generator so the two cannot drift apart silently.
*/
import { isComparableVersion } from "./version";

const CHANGELOG_BASE_URL = "https://hunk.dev/changelog";

/**
* The per-minor series page one version's notes live on: `1.2.3-beta.0` -> `1.2`.
*
* Prerelease identifiers are dropped first, so a prerelease lands on the same series page as the
* stable release it leads to — which is where the generator puts it.
*/
function minorSeriesOf(version: string) {
const [major = "0", minor = "0"] = version.split("-", 1)[0]?.split(".") ?? [];
return `${major}.${minor}`;
}

/**
* The in-page anchor for one release: `0.21.0-beta.0` -> `v0-21-0-beta-0`.
*
* Every dot becomes a dash, including those inside a prerelease identifier, and the `v` prefix
* keeps the id from starting with a digit.
*/
function versionAnchor(version: string) {
return `v${version.replaceAll(".", "-")}`;
}

/**
* Return the release-notes URL for one version, or undefined when it has no page to point at.
*
* Fails closed on anything that is not a normalized semver — a dev build, a distro-patched string,
* `0.0.0-unknown` — because a plausible-looking URL to a page that does not exist is worse than
* printing no link at all.
*/
export function releaseNotesUrl(version: string): string | undefined {
if (!isComparableVersion(version)) {
return undefined;
}

return `${CHANGELOG_BASE_URL}/${minorSeriesOf(version)}/#${versionAnchor(version)}`;
}