fix(coding-agent): fix changelog links

Fixes #5516
This commit is contained in:
Armin Ronacher
2026-06-08 20:31:20 +02:00
Unverified
parent 2edd6b432a
commit 20b78eafb4
7 changed files with 543 additions and 32 deletions
+25 -29
View File
@@ -51,41 +51,37 @@ jobs:
run: |
VERSION="${RELEASE_TAG}"
VERSION="${VERSION#v}" # Remove 'v' prefix
# Extract changelog section for this version
cd packages/coding-agent
awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md > /tmp/release-notes.md
# If empty, use a default message
if [ ! -s /tmp/release-notes.md ]; then
echo "Release ${VERSION}" > /tmp/release-notes.md
fi
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md
- name: Create GitHub Release and upload binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd packages/coding-agent/binaries
# Create release with changelog notes (or update if exists)
gh release create "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md \
pi-darwin-arm64.tar.gz \
pi-darwin-x64.tar.gz \
pi-linux-x64.tar.gz \
pi-linux-arm64.tar.gz \
pi-windows-x64.zip \
pi-windows-arm64.zip \
2>/dev/null || \
gh release upload "${RELEASE_TAG}" \
pi-darwin-arm64.tar.gz \
pi-darwin-x64.tar.gz \
pi-linux-x64.tar.gz \
pi-linux-arm64.tar.gz \
pi-windows-x64.zip \
pi-windows-arm64.zip \
--clobber
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
gh release edit "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md
gh release upload "${RELEASE_TAG}" \
pi-darwin-arm64.tar.gz \
pi-darwin-x64.tar.gz \
pi-linux-x64.tar.gz \
pi-linux-arm64.tar.gz \
pi-windows-x64.zip \
pi-windows-arm64.zip \
--clobber
else
gh release create "${RELEASE_TAG}" \
--title "${RELEASE_TAG}" \
--notes-file /tmp/release-notes.md \
pi-darwin-arm64.tar.gz \
pi-darwin-x64.tar.gz \
pi-linux-x64.tar.gz \
pi-linux-arm64.tar.gz \
pi-windows-x64.zip \
pi-windows-arm64.zip
fi
publish-npm:
runs-on: ubuntu-latest
+1
View File
@@ -33,6 +33,7 @@
"release:patch": "node scripts/release.mjs patch",
"release:minor": "node scripts/release.mjs minor",
"release:major": "node scripts/release.mjs major",
"release:fix-links": "node scripts/release-notes.mjs fix-github-releases",
"prepare": "husky"
},
"devDependencies": {
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)).
## [0.79.0] - 2026-06-08
### New Features
@@ -87,7 +87,7 @@ import type { SourceInfo } from "../../core/source-info.ts";
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts";
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
import { parseGitUrl } from "../../utils/git.ts";
@@ -909,7 +909,7 @@ export class InteractiveMode {
if (newEntries.length > 0) {
this.settingsManager.setLastChangelogVersion(VERSION);
this.reportInstallTelemetry(VERSION);
return newEntries.map((e) => e.content).join("\n\n");
return newEntries.map((e) => normalizeChangelogLinks(e.content, e)).join("\n\n");
}
return undefined;
@@ -5380,7 +5380,7 @@ export class InteractiveMode {
allEntries.length > 0
? allEntries
.reverse()
.map((e) => e.content)
.map((e) => normalizeChangelogLinks(e.content, e))
.join("\n\n")
: "No changelog entries found.";
@@ -1,3 +1,4 @@
import path from "node:path";
import { existsSync, readFileSync } from "fs";
export interface ChangelogEntry {
@@ -7,6 +8,102 @@ export interface ChangelogEntry {
content: string;
}
const GITHUB_REPO = "earendil-works/pi";
const CHANGELOG_LINK_BASE_PATH = "packages/coding-agent";
const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/;
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g;
function entryVersion(entry: ChangelogEntry): string {
return `${entry.major}.${entry.minor}.${entry.patch}`;
}
function normalizeTag(version: string | ChangelogEntry): string {
const versionString = typeof version === "string" ? version : entryVersion(version);
return versionString.startsWith("v") ? versionString : `v${versionString}`;
}
function splitLocalTarget(target: string): { fragment: string; pathPart: string; query: string } {
const hashIndex = target.indexOf("#");
const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex);
const fragment = hashIndex === -1 ? "" : target.slice(hashIndex);
const queryIndex = beforeHash.indexOf("?");
if (queryIndex === -1) {
return { fragment, pathPart: beforeHash, query: "" };
}
return {
fragment,
pathPart: beforeHash.slice(0, queryIndex),
query: beforeHash.slice(queryIndex),
};
}
function normalizePathPart(value: string): string {
return value.replaceAll("\\", "/");
}
function resolveRepositoryPath(targetPath: string): string | undefined {
const normalizedTarget = normalizePathPart(targetPath);
const joined = normalizedTarget.startsWith("/")
? path.posix.normalize(normalizedTarget.replace(/^\/+/, ""))
: path.posix.normalize(path.posix.join(CHANGELOG_LINK_BASE_PATH, normalizedTarget));
if (joined === "." || joined.startsWith("../") || joined === "..") {
return undefined;
}
return joined;
}
function isDirectoryTarget(originalPath: string, repositoryPath: string): boolean {
if (originalPath.endsWith("/")) {
return true;
}
const basename = path.posix.basename(repositoryPath);
return !basename.includes(".");
}
function normalizeChangelogLinkTarget(target: string, tag: string): string {
let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${GITHUB_REPO}`);
const repoUrl = `https://github.com/${GITHUB_REPO}`;
for (const route of ["blob", "tree"]) {
for (const branch of ["main", "master"]) {
const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`;
if (canonicalTarget.startsWith(floatingRefPrefix)) {
canonicalTarget = `${repoUrl}/${route}/${tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`;
}
}
}
if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) {
return canonicalTarget;
}
const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget);
if (!pathPart) {
return canonicalTarget;
}
const repositoryPath = resolveRepositoryPath(pathPart);
if (!repositoryPath) {
return canonicalTarget;
}
const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob";
return `https://github.com/${GITHUB_REPO}/${route}/${tag}/${encodeURI(repositoryPath)}${query}${fragment}`;
}
export function normalizeChangelogLinks(markdown: string, version: string | ChangelogEntry): string {
const tag = normalizeTag(version);
return markdown.replace(INLINE_MARKDOWN_LINK_RE, (_match, prefix, target, suffix) => {
return `${prefix}${normalizeChangelogLinkTarget(target, tag)}${suffix}`;
});
}
/**
* Parse changelog entries from CHANGELOG.md
* Scans for ## lines and collects content until next ## or EOF
@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import { type ChangelogEntry, normalizeChangelogLinks } from "../src/utils/changelog.ts";
const entry: ChangelogEntry = {
major: 0,
minor: 79,
patch: 0,
content: "",
};
describe("normalizeChangelogLinks", () => {
test("rewrites package-relative changelog links to tag-pinned GitHub source links", () => {
const markdown = [
"[Project Trust](README.md#project-trust)",
"[Extensions](docs/extensions.md#project_trust)",
"[Examples](examples/extensions/)",
"[Root README](../../README.md#supply-chain-hardening)",
].join("\n");
expect(normalizeChangelogLinks(markdown, entry)).toBe(
[
"[Project Trust](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/README.md#project-trust)",
"[Extensions](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/docs/extensions.md#project_trust)",
"[Examples](https://github.com/earendil-works/pi/tree/v0.79.0/packages/coding-agent/examples/extensions/)",
"[Root README](https://github.com/earendil-works/pi/blob/v0.79.0/README.md#supply-chain-hardening)",
].join("\n"),
);
});
test("canonicalizes old repository URLs without changing external links", () => {
const markdown = [
"[#5167](https://github.com/earendil-works/pi-mono/pull/5167)",
"[#4163](https://github.com/badlogic/pi-mono/issues/4163)",
"[Agent README](https://github.com/badlogic/pi-mono/blob/main/packages/agent/README.md)",
"[External](https://example.com/docs)",
"[Local anchor](#settings)",
].join("\n");
expect(normalizeChangelogLinks(markdown, "0.79.0")).toBe(
[
"[#5167](https://github.com/earendil-works/pi/pull/5167)",
"[#4163](https://github.com/earendil-works/pi/issues/4163)",
"[Agent README](https://github.com/earendil-works/pi/blob/v0.79.0/packages/agent/README.md)",
"[External](https://example.com/docs)",
"[Local anchor](#settings)",
].join("\n"),
);
});
});
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
const DEFAULT_REPO = "earendil-works/pi";
const DEFAULT_BASE_PATH = "packages/coding-agent";
const DEFAULT_CHANGELOG = "packages/coding-agent/CHANGELOG.md";
const DEFAULT_FIX_SINCE_TAG = "v0.74.0";
const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/;
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g;
function printUsage() {
console.log(`Usage: node scripts/release-notes.mjs <command> [options]
Commands:
extract Extract release notes from the coding-agent changelog
fix-github-releases Rewrite existing GitHub release note links in place
extract options:
--version <x.y.z> Version to extract
--tag <vX.Y.Z> Release tag used for repository links (defaults to v<version>)
--changelog <path> Changelog path (default: ${DEFAULT_CHANGELOG})
--out <path> Output file (default: stdout)
--repo <owner/repo> GitHub repository for generated links (default: ${DEFAULT_REPO})
--base-path <path> Base path for relative changelog links (default: ${DEFAULT_BASE_PATH})
fix-github-releases options:
--repo <owner/repo> GitHub repository to patch (default: ${DEFAULT_REPO})
--tag <vX.Y.Z> Patch only one release tag
--since-tag <vX.Y.Z> Oldest release tag to patch (default: ${DEFAULT_FIX_SINCE_TAG})
--base-path <path> Base path for relative changelog links (default: ${DEFAULT_BASE_PATH})
--dry-run Print releases that would change without updating GitHub
`);
}
function commandForPlatform(command) {
return process.platform === "win32" ? `${command}.cmd` : command;
}
function run(command, args, options = {}) {
const result = spawnSync(commandForPlatform(command), args, {
cwd: options.cwd,
encoding: "utf8",
maxBuffer: options.maxBuffer ?? 20 * 1024 * 1024,
stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit",
});
if (result.status !== 0) {
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`);
}
return result.stdout ?? "";
}
function parseOptions(args) {
const options = {
basePath: DEFAULT_BASE_PATH,
changelog: DEFAULT_CHANGELOG,
dryRun: false,
out: undefined,
repo: DEFAULT_REPO,
sinceTag: DEFAULT_FIX_SINCE_TAG,
tag: undefined,
version: undefined,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--help") {
printUsage();
process.exit(0);
}
if (arg === "--dry-run") {
options.dryRun = true;
continue;
}
const optionNames = new Set(["--base-path", "--changelog", "--out", "--repo", "--since-tag", "--tag", "--version"]);
if (!optionNames.has(arg)) {
throw new Error(`Unknown option: ${arg}`);
}
const value = args[++i];
if (!value) {
throw new Error(`${arg} requires a value`);
}
if (arg === "--base-path") options.basePath = value;
if (arg === "--changelog") options.changelog = value;
if (arg === "--out") options.out = value;
if (arg === "--repo") options.repo = value;
if (arg === "--since-tag") options.sinceTag = value;
if (arg === "--tag") options.tag = value;
if (arg === "--version") options.version = value;
}
return options;
}
function normalizeTag(tagOrVersion) {
if (!tagOrVersion) {
return undefined;
}
return tagOrVersion.startsWith("v") ? tagOrVersion : `v${tagOrVersion}`;
}
function versionFromTag(tag) {
return tag.startsWith("v") ? tag.slice(1) : tag;
}
function compareVersions(a, b) {
const aParts = versionFromTag(a).split(".").map(Number);
const bParts = versionFromTag(b).split(".").map(Number);
for (let i = 0; i < 3; i++) {
const diff = (aParts[i] || 0) - (bParts[i] || 0);
if (diff !== 0) {
return diff;
}
}
return 0;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function extractChangelogSection(changelog, version) {
const headingRe = new RegExp(`^## \\[${escapeRegExp(version)}\\](?:\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*$`, "m");
const heading = headingRe.exec(changelog);
if (!heading) {
return "";
}
const sectionStart = heading.index + heading[0].length;
const rest = changelog.slice(sectionStart);
const nextHeading = rest.search(/^## \[/m);
const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
return section.trim();
}
function splitLocalTarget(target) {
const hashIndex = target.indexOf("#");
const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex);
const fragment = hashIndex === -1 ? "" : target.slice(hashIndex);
const queryIndex = beforeHash.indexOf("?");
if (queryIndex === -1) {
return { fragment, pathPart: beforeHash, query: "" };
}
return {
fragment,
pathPart: beforeHash.slice(0, queryIndex),
query: beforeHash.slice(queryIndex),
};
}
function normalizePathPart(value) {
return value.replaceAll("\\", "/");
}
function normalizeBasePath(basePath) {
const normalized = path.posix.normalize(normalizePathPart(basePath)).replace(/\/+$/, "");
return normalized === "." ? "" : normalized;
}
function resolveRepositoryPath(targetPath, basePath) {
const normalizedTarget = normalizePathPart(targetPath);
const joined = normalizedTarget.startsWith("/")
? path.posix.normalize(normalizedTarget.replace(/^\/+/, ""))
: path.posix.normalize(path.posix.join(normalizeBasePath(basePath), normalizedTarget));
if (joined === "." || joined.startsWith("../") || joined === "..") {
return undefined;
}
return joined;
}
function isDirectoryTarget(originalPath, repositoryPath) {
if (originalPath.endsWith("/")) {
return true;
}
const basename = path.posix.basename(repositoryPath);
return !basename.includes(".");
}
function normalizeLinkTarget(target, options) {
let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${options.repo}`);
const repoUrl = `https://github.com/${options.repo}`;
for (const route of ["blob", "tree"]) {
for (const branch of ["main", "master"]) {
const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`;
if (canonicalTarget.startsWith(floatingRefPrefix)) {
canonicalTarget = `${repoUrl}/${route}/${options.tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`;
}
}
}
if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) {
return canonicalTarget;
}
const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget);
if (!pathPart) {
return canonicalTarget;
}
const repositoryPath = resolveRepositoryPath(pathPart, options.basePath);
if (!repositoryPath) {
return canonicalTarget;
}
const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob";
return `https://github.com/${options.repo}/${route}/${options.tag}/${encodeURI(repositoryPath)}${query}${fragment}`;
}
function normalizeReleaseNoteLinks(markdown, options) {
const changes = [];
const normalized = markdown.replace(INLINE_MARKDOWN_LINK_RE, (match, prefix, target, suffix) => {
const normalizedTarget = normalizeLinkTarget(target, options);
if (normalizedTarget !== target) {
changes.push({ from: target, to: normalizedTarget });
}
return `${prefix}${normalizedTarget}${suffix}`;
});
return { changes, markdown: normalized };
}
function writeOutput(content, outPath) {
if (outPath) {
writeFileSync(outPath, content);
return;
}
process.stdout.write(content);
}
function extractReleaseNotes(options) {
const version = options.version ?? (options.tag ? versionFromTag(options.tag) : undefined);
if (!version) {
throw new Error("extract requires --version or --tag");
}
if (!existsSync(options.changelog)) {
throw new Error(`Changelog does not exist: ${options.changelog}`);
}
const tag = normalizeTag(options.tag ?? version);
const changelog = readFileSync(options.changelog, "utf8");
const section = extractChangelogSection(changelog, version);
const rawNotes = section ? `${section}\n` : `Release ${version}\n`;
const { markdown } = normalizeReleaseNoteLinks(rawNotes, { basePath: options.basePath, repo: options.repo, tag });
writeOutput(markdown, options.out);
}
function listGithubReleases(repo) {
const output = run("gh", ["api", `repos/${repo}/releases`, "--paginate", "--jq", ".[] | {id, tag_name, body} | @json"], {
capture: true,
});
return output
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line));
}
function uniqueChanges(changes) {
const seen = new Set();
const unique = [];
for (const change of changes) {
const key = `${change.from}\n${change.to}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
unique.push(change);
}
return unique;
}
function updateGithubRelease(repo, tag, body) {
const tempDir = mkdtempSync(path.join(tmpdir(), "pi-release-notes-"));
try {
const notesPath = path.join(tempDir, "notes.md");
writeFileSync(notesPath, body);
run("gh", ["release", "edit", tag, "--repo", repo, "--notes-file", notesPath], { capture: true });
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
}
function fixGithubReleases(options) {
const tagFilter = normalizeTag(options.tag);
const sinceTag = normalizeTag(options.sinceTag);
const matchingReleases = listGithubReleases(options.repo).filter((release) => !tagFilter || release.tag_name === tagFilter);
if (tagFilter && matchingReleases.length === 0) {
throw new Error(`Release not found: ${tagFilter}`);
}
const releases = matchingReleases.filter((release) => compareVersions(release.tag_name, sinceTag) >= 0);
if (tagFilter && releases.length === 0) {
console.log(`Skipping ${tagFilter}: older than ${sinceTag}.`);
console.log(`${options.dryRun ? "Would update" : "Updated"} 0 releases.`);
return;
}
let changedCount = 0;
for (const release of releases) {
const tag = release.tag_name;
const body = release.body ?? "";
const result = normalizeReleaseNoteLinks(body, { basePath: options.basePath, repo: options.repo, tag });
if (result.markdown === body) {
continue;
}
changedCount++;
const unique = uniqueChanges(result.changes);
console.log(`${options.dryRun ? "Would update" : "Updating"} ${tag} (${unique.length} link${unique.length === 1 ? "" : "s"})`);
for (const change of unique) {
console.log(` ${change.from}`);
console.log(` -> ${change.to}`);
}
if (!options.dryRun) {
updateGithubRelease(options.repo, tag, result.markdown);
}
}
const prefix = options.dryRun ? "Would update" : "Updated";
console.log(`${prefix} ${changedCount} release${changedCount === 1 ? "" : "s"}.`);
}
try {
const [command, ...args] = process.argv.slice(2);
if (!command || command === "--help") {
printUsage();
process.exit(command ? 0 : 1);
}
const options = parseOptions(args);
if (command === "extract") {
extractReleaseNotes(options);
} else if (command === "fix-github-releases") {
fixGithubReleases(options);
} else {
throw new Error(`Unknown command: ${command}`);
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}