Skip to content

Commit 9c8f0b3

Browse files
committed
chore(release): link each CHANGELOG entry to its compare range
Changesets writes `## <version>` and the notes beneath it, and nothing about which commits a release actually contained. Reviewing "what shipped in 0.11.0" meant finding the version-bump commit by hand, which is harder than it sounds: every one of them is called `chore: version packages`, so the subject line cannot tell them apart and you have to read the version out of `package.json` at each candidate. `scripts/changelog-compare-links.ts` adds a `vPREV...vNEXT` compare link under each heading, and runs from `pnpm bump` — which is what the Version Packages workflow invokes — so the link lands in the same PR as the notes it describes. The tag it points at does not exist yet at that moment; `release-cli.yml` creates it after publishing, so the newest link is dead for the minutes in between and correct forever after. Releases before 0.9.0 were never tagged and will not be, so a link whose left side is `v0.8.1` would be permanently broken rather than briefly so. A documented `FIRST_TAGGED` floor leaves those headings alone; 0.9.0 itself gets no link for the same reason. Backfilled 0.10.0, 0.10.1 and 0.10.2, whose tags were just created. Both `v0.10.1...v0.10.2` and `v0.9.0...v0.10.0` return HTTP 200. Re-running is a no-op, and a simulated 0.11.0 heading takes exactly one new link.
1 parent 5b4cb50 commit 9c8f0b3

4 files changed

Lines changed: 120 additions & 1 deletion

File tree

‎.github/workflows/release-cli-changeset.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ jobs:
5959

6060
# `version: pnpm bump` runs `changeset version` AND `sync-skill-versions`,
6161
# so the bumped version is propagated into skills/recipes in the same PR.
62+
# It also runs `changelog-compare-links`, which adds the
63+
# `vPREV...vNEXT` compare link under the new CHANGELOG heading. That link
64+
# is dead until `release-cli.yml` creates the tag after publishing, which
65+
# is expected: the notes and the link they describe belong in the same PR.
6266
# No `publish:` input — this job can never publish.
6367
- uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1
6468
with:

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"build:self": "run-s build:self:compile build:self:install",
1212
"build:self:compile": "pnpm --filter @taskless/cli build:self",
1313
"build:self:install": "node packages/cli/dist-self/index.js init --no-interactive",
14-
"bump": "run-s bump:version bump:sync",
14+
"bump": "run-s bump:version bump:changelog bump:sync",
15+
"bump:changelog": "tsx scripts/changelog-compare-links.ts",
1516
"bump:sync": "tsx scripts/sync-skill-versions.ts",
1617
"bump:version": "changeset version",
1718
"changeset": "changeset",

‎packages/cli/CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## 0.10.2
44

5+
[Compare with v0.10.1](https://github.com/taskless/cli/compare/v0.10.1...v0.10.2)
6+
57
### Patch Changes
68

79
- 1cd0f89: Treat the Taskless organization UUID as the one canonical identity, and become
@@ -34,6 +36,8 @@
3436

3537
## 0.10.1
3638

39+
[Compare with v0.10.0](https://github.com/taskless/cli/compare/v0.10.0...v0.10.1)
40+
3741
### Patch Changes
3842

3943
- 53b2d30: Add a help recipe for the `detect` command.
@@ -47,6 +51,8 @@
4751

4852
## 0.10.0
4953

54+
[Compare with v0.9.0](https://github.com/taskless/cli/compare/v0.9.0...v0.10.0)
55+
5056
### Minor Changes
5157

5258
- da74920: Send an `x-taskless-cli-version` header on every request to the Taskless service (rule generation, reconcile, `whoami`, and the device-auth flow) declaring the CLI's version. The service uses this to gate capability-dependent responses — notably runtime rules — on the CLI being new enough to handle them; a request without the header is treated as a pre-runtime CLI. The version is also emitted with the CLI's telemetry so usage is recorded client-side rather than inferred server-side.

‎scripts/changelog-compare-links.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { readFileSync, writeFileSync } from "node:fs";
2+
import { join, resolve } from "node:path";
3+
4+
/**
5+
* Add a compare link under each version heading in the CLI's CHANGELOG.
6+
*
7+
* Changesets writes `## <version>` and the notes beneath it, and nothing that
8+
* says which commits a release actually contained. Reviewing "what shipped in
9+
* 0.11.0" then means finding the version-bump commit by hand, which is harder
10+
* than it sounds because every one of them is called `chore: version packages`.
11+
*
12+
* Runs as part of `pnpm bump`, so the link lands in the Version Packages PR
13+
* alongside the notes it describes. The tag it points at does not exist yet at
14+
* that moment: `release-cli.yml` creates it after the publish. The link is dead
15+
* for the minutes in between and correct forever after, which is the accepted
16+
* trade rather than an oversight.
17+
*/
18+
19+
const ROOT = resolve(import.meta.dirname, "..");
20+
const CHANGELOG = join(ROOT, "packages", "cli", "CHANGELOG.md");
21+
const CLI_PACKAGE_JSON = join(ROOT, "packages", "cli", "package.json");
22+
23+
/**
24+
* The oldest release with a tag, so the oldest version that can be the LEFT
25+
* side of a compare link.
26+
*
27+
* Tagging began here: `v0.9.0` through `v0.10.2` were created retroactively,
28+
* and releases before `0.9.0` were never tagged and will not be. Emitting a
29+
* link whose left side is `v0.8.1` would produce a permanently broken URL,
30+
* which is worse than no link, so those headings are left alone.
31+
*/
32+
const FIRST_TAGGED = "0.9.0";
33+
34+
/** `## 1.2.3` at the start of a line. Changesets writes nothing else here. */
35+
const HEADING = /^## (\d+\.\d+\.\d+)\s*$/;
36+
37+
/** A link this script already wrote, so a re-run is a no-op. */
38+
const COMPARE_LINK = /^\[Compare with v\d+\.\d+\.\d+\]\(/;
39+
40+
/** Numeric ordering. Every version here is a plain release, never a prerelease. */
41+
function isAtLeast(version: string, floor: string): boolean {
42+
const a = version.split(".").map(Number);
43+
const b = floor.split(".").map(Number);
44+
for (let index = 0; index < 3; index++) {
45+
if ((a[index] ?? 0) !== (b[index] ?? 0))
46+
return (a[index] ?? 0) > (b[index] ?? 0);
47+
}
48+
return true;
49+
}
50+
51+
/**
52+
* Insert a compare link beneath every heading that has an older sibling, and
53+
* return the new text. Pure, so it can be exercised without touching the file.
54+
*/
55+
export function withCompareLinks(
56+
markdown: string,
57+
repositoryUrl: string
58+
): string {
59+
const lines = markdown.split("\n");
60+
const headings: Array<{ index: number; version: string }> = [];
61+
for (const [index, line] of lines.entries()) {
62+
const match = HEADING.exec(line);
63+
if (match?.[1]) headings.push({ index, version: match[1] });
64+
}
65+
66+
// Back to front, so an insertion never shifts an index still to be used.
67+
for (let position = headings.length - 2; position >= 0; position--) {
68+
const current = headings[position]!;
69+
const previous = headings[position + 1]!;
70+
if (!isAtLeast(previous.version, FIRST_TAGGED)) continue;
71+
72+
// Idempotent: the first non-empty line after the heading decides.
73+
let cursor = current.index + 1;
74+
while (cursor < lines.length && lines[cursor]!.trim() === "") cursor++;
75+
if (COMPARE_LINK.test(lines[cursor] ?? "")) continue;
76+
77+
const from = `v${previous.version}`;
78+
const to = `v${current.version}`;
79+
lines.splice(
80+
current.index + 1,
81+
0,
82+
"",
83+
`[Compare with ${from}](${repositoryUrl}/compare/${from}...${to})`
84+
);
85+
}
86+
87+
return lines.join("\n");
88+
}
89+
90+
/** `https://github.com/owner/repo`, from the package's own metadata. */
91+
function repositoryUrl(): string {
92+
const manifest = JSON.parse(readFileSync(CLI_PACKAGE_JSON, "utf8")) as {
93+
repository?: { url?: string };
94+
};
95+
const raw = manifest.repository?.url;
96+
if (!raw) throw new Error("packages/cli/package.json has no repository.url");
97+
return raw.replace(/^git\+/, "").replace(/\.git$/, "");
98+
}
99+
100+
const before = readFileSync(CHANGELOG, "utf8");
101+
const after = withCompareLinks(before, repositoryUrl());
102+
if (before === after) {
103+
console.log("CHANGELOG compare links: already up to date.");
104+
} else {
105+
writeFileSync(CHANGELOG, after, "utf8");
106+
const added = after.split("\n").length - before.split("\n").length;
107+
console.log(`CHANGELOG compare links: added ${added / 2} link(s).`);
108+
}

0 commit comments

Comments
 (0)