Skip to content

Commit a455a66

Browse files
committed
feat(upgrade): show changelog summary during CLI upgrade
Show inline changelog summaries when running `sentry cli upgrade`. When upgrading across multiple versions, displays a flat summary of new features, bug fixes, and performance improvements from all intermediate releases. - Parse GitHub Release bodies via `marked.lexer()` AST for stable channel - Parse conventional commit messages via GitHub Commits API for nightly - Fetch runs in parallel with binary download (zero added latency) - Filter to features/fixes/performance only (skip docs, internal changes) - Clamp output to ~1.3x terminal height to keep it scannable - Works with --check mode (preview what you would get) - Graceful degradation: offline, rate-limited, or network errors → no changelog Closes #421
1 parent 664362c commit a455a66

File tree

7 files changed

+1405
-37
lines changed

7 files changed

+1405
-37
lines changed

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,9 @@ mock.module("./some-module", () => ({
899899
<!-- lore:019d0b69-1430-74f0-8e9a-426a5c7b321d -->
900900
* **Bun compiled binary sourcemap options and size impact**: Binary build (\`script/build.ts\`) uses two steps: (1) \`Bun.build()\` produces \`dist-bin/bin.js\` + \`.map\` with \`sourcemap: "linked"\` and minification. (2) \`Bun.build()\` with \`compile: true\` produces native binary — no sourcemap embedded. Bun's compiled binaries use \`/$bunfs/root/bin.js\` as the virtual path in stack traces. Sourcemap upload must use \`--url-prefix '/$bunfs/root/'\` so Sentry can match frames. The upload runs \`sentry-cli sourcemaps inject dist-bin/\` first (adds debug IDs), then uploads both JS and map. Bun's compile step strips comments (including \`//# debugId=\`), but debug ID matching still works via the injected runtime snippet + URL prefix matching. Size: +0.04 MB gzipped vs +2.30 MB for inline sourcemaps. Without \`SENTRY\_AUTH\_TOKEN\`, upload is skipped gracefully.
901901
902+
<!-- lore:019cbeba-e4d3-748c-ad50-fe3c3d5c0a0d -->
903+
* **Auth token env var override pattern: SENTRY\_AUTH\_TOKEN > SENTRY\_TOKEN > SQLite**: Auth in \`src/lib/db/auth.ts\` follows layered precedence: \`SENTRY\_AUTH\_TOKEN\` > \`SENTRY\_TOKEN\` > SQLite OAuth token. \`getEnvToken()\` trims env vars (empty/whitespace = unset). \`AuthSource\` tracks provenance. \`ENV\_SOURCE\_PREFIX = "env:"\` — use \`.length\` not hardcoded 4. Env tokens bypass refresh/expiry. \`isEnvTokenActive()\` guards auth commands. Logout must NOT clear stored auth when env token active. These functions stay in \`db/auth.ts\` despite not touching DB because they're tightly coupled with token retrieval.
904+
902905
<!-- lore:019cb8ea-c6f0-75d8-bda7-e32b4e217f92 -->
903906
* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`.
904907

src/commands/cli/upgrade.ts

Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ import { formatUpgradeResult } from "../../lib/formatters/human.js";
3535
import { CommandOutput } from "../../lib/formatters/output.js";
3636
import { logger } from "../../lib/logger.js";
3737
import { withProgress } from "../../lib/polling.js";
38+
import {
39+
type ChangelogSummary,
40+
fetchChangelog,
41+
} from "../../lib/release-notes.js";
3842
import {
3943
detectInstallationMethod,
4044
executeUpgrade,
@@ -76,6 +80,8 @@ export type UpgradeResult = {
7680
offline?: boolean;
7781
/** Warnings to display (e.g., PATH shadowing from old package manager install) */
7882
warnings?: string[];
83+
/** Changelog summary for the version range. Absent for offline or on fetch failure. */
84+
changelog?: ChangelogSummary;
7985
};
8086

8187
type UpgradeFlags = {
@@ -592,6 +598,50 @@ function persistChannel(
592598
}
593599
}
594600

601+
/**
602+
* Start a best-effort changelog fetch in parallel with the binary download.
603+
*
604+
* Returns a promise that resolves to the changelog or undefined. Never
605+
* throws — errors are swallowed so the upgrade is not blocked.
606+
*/
607+
function startChangelogFetch(
608+
channel: ReleaseChannel,
609+
currentVersion: string,
610+
targetVersion: string,
611+
offline: boolean
612+
): Promise<ChangelogSummary | undefined> {
613+
if (offline || currentVersion === targetVersion) {
614+
return Promise.resolve(undefined);
615+
}
616+
return fetchChangelog({
617+
channel,
618+
fromVersion: currentVersion,
619+
toVersion: targetVersion,
620+
})
621+
.then((result) => result ?? undefined)
622+
.catch(() => undefined as undefined);
623+
}
624+
625+
/**
626+
* Build a check-only result with optional changelog, ready to yield.
627+
*/
628+
async function buildCheckResultWithChangelog(opts: {
629+
target: string;
630+
versionArg: string | undefined;
631+
method: InstallationMethod;
632+
channel: ReleaseChannel;
633+
flags: UpgradeFlags;
634+
offline: boolean;
635+
changelogPromise: Promise<ChangelogSummary | undefined>;
636+
}): Promise<UpgradeResult> {
637+
const result = buildCheckResult(opts);
638+
if (opts.offline) {
639+
result.offline = true;
640+
}
641+
result.changelog = await opts.changelogPromise;
642+
return result;
643+
}
644+
595645
export const upgradeCommand = buildCommand({
596646
docs: {
597647
brief: "Update the Sentry CLI to the latest version",
@@ -672,25 +722,48 @@ export const upgradeCommand = buildCommand({
672722
persistChannel(channel, channelChanged, version),
673723
})
674724
);
725+
// Early exit for check-only (online) and up-to-date results.
675726
if (resolved.kind === "done") {
676-
return yield new CommandOutput(resolved.result);
727+
const result = resolved.result;
728+
// For --check with a version diff, fetch changelog before returning.
729+
if (
730+
result.action === "checked" &&
731+
result.currentVersion !== result.targetVersion
732+
) {
733+
result.changelog = await startChangelogFetch(
734+
channel,
735+
CLI_VERSION,
736+
result.targetVersion,
737+
false
738+
);
739+
}
740+
return yield new CommandOutput(result);
677741
}
678742

679743
const { target, offline } = resolved;
680744

681-
// --check with offline just reports the cached version
745+
// Start changelog fetch early — it runs in parallel with the download.
746+
const changelogPromise = startChangelogFetch(
747+
channel,
748+
CLI_VERSION,
749+
target,
750+
offline
751+
);
752+
753+
// --check with offline fallback: resolveTargetWithFallback returns
754+
// kind: "target" for offline check, so guard against actual upgrade.
682755
if (flags.check) {
683-
const checkResult = buildCheckResult({
684-
target,
685-
versionArg,
686-
method,
687-
channel,
688-
flags,
689-
});
690-
if (offline) {
691-
checkResult.offline = true;
692-
}
693-
return yield new CommandOutput(checkResult);
756+
return yield new CommandOutput(
757+
await buildCheckResultWithChangelog({
758+
target,
759+
versionArg,
760+
method,
761+
channel,
762+
flags,
763+
offline,
764+
changelogPromise,
765+
})
766+
);
694767
}
695768

696769
// Skip if already on target — unless forced or switching channels
@@ -708,38 +781,30 @@ export const upgradeCommand = buildCommand({
708781
const downgrade = isDowngrade(CLI_VERSION, target);
709782
log.debug(`${downgrade ? "Downgrading" : "Upgrading"} to ${target}`);
710783

711-
// Nightly is GitHub-only. If the current install method is not curl,
712-
// migrate to a standalone binary first then return — the migration
713-
// handles setup internally.
784+
// Perform the actual upgrade
785+
let warnings: string[] | undefined;
714786
if (channel === "nightly" && method !== "curl") {
715-
const warnings = await migrateToStandaloneForNightly(
787+
// Nightly is GitHub-only. If the current install method is not curl,
788+
// migrate to a standalone binary — the migration handles setup internally.
789+
warnings = await migrateToStandaloneForNightly(
716790
method,
717791
target,
718792
versionArg,
719793
flags.json
720794
);
721-
yield new CommandOutput({
722-
action: downgrade ? "downgraded" : "upgraded",
723-
currentVersion: CLI_VERSION,
724-
targetVersion: target,
725-
channel,
795+
} else {
796+
await executeStandardUpgrade({
726797
method,
727-
forced: flags.force,
728-
warnings,
729-
} satisfies UpgradeResult);
730-
return;
798+
channel,
799+
versionArg,
800+
target,
801+
execPath: this.process.execPath,
802+
offline,
803+
json: flags.json,
804+
});
731805
}
732806

733-
await executeStandardUpgrade({
734-
method,
735-
channel,
736-
versionArg,
737-
target,
738-
execPath: this.process.execPath,
739-
offline,
740-
json: flags.json,
741-
});
742-
807+
const changelog = await changelogPromise;
743808
yield new CommandOutput({
744809
action: downgrade ? "downgraded" : "upgraded",
745810
currentVersion: CLI_VERSION,
@@ -748,6 +813,8 @@ export const upgradeCommand = buildCommand({
748813
method,
749814
forced: flags.force,
750815
offline: offline || undefined,
816+
warnings,
817+
changelog,
751818
} satisfies UpgradeResult);
752819
return;
753820
},

src/lib/delta-upgrade.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ export type GitHubAsset = {
146146
export type GitHubRelease = {
147147
tag_name: string;
148148
assets: GitHubAsset[];
149+
/** Markdown release notes body. May be empty or absent for pre-releases. */
150+
body?: string;
149151
};
150152

151153
/**

src/lib/formatters/human.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2080,6 +2080,101 @@ export function formatFixResult(data: FixResult): string {
20802080
/** Structured upgrade result (imported from the command module) */
20812081
type UpgradeResult = import("../../commands/cli/upgrade.js").UpgradeResult;
20822082

2083+
/** Category → markdown heading for changelog sections */
2084+
type ChangeCategory = import("../release-notes.js").ChangeCategory;
2085+
2086+
/** Heading text for each changelog category */
2087+
const CATEGORY_HEADINGS: Record<ChangeCategory, string> = {
2088+
features: "#### New Features ✨",
2089+
fixes: "#### Bug Fixes 🐛",
2090+
performance: "#### Performance ⚡",
2091+
};
2092+
2093+
/**
2094+
* Max terminal height multiplier for changelog clamping.
2095+
*
2096+
* When the total rendered changelog would exceed this fraction of the
2097+
* terminal height, items are truncated to keep output scannable.
2098+
*/
2099+
const CHANGELOG_HEIGHT_FACTOR = 1.3;
2100+
2101+
/** Lines consumed by the upgrade status header/metadata above the changelog */
2102+
const CHANGELOG_HEADER_OVERHEAD = 6;
2103+
2104+
/** Minimum number of changelog items to show even on tiny terminals */
2105+
const MIN_CHANGELOG_ITEMS = 5;
2106+
2107+
/** Default max items for non-TTY output (no terminal height available) */
2108+
const DEFAULT_MAX_CHANGELOG_ITEMS = 30;
2109+
2110+
/**
2111+
* Compute the maximum number of changelog lines based on terminal height.
2112+
*
2113+
* Uses ~1.3x the terminal height minus header overhead. Returns a generous
2114+
* default for non-TTY output where terminal height is unknown.
2115+
*/
2116+
function getMaxChangelogLines(): number {
2117+
// process.stdout.rows is allowed in formatters (not in command files)
2118+
const termHeight = process.stdout.rows;
2119+
if (!termHeight) {
2120+
return DEFAULT_MAX_CHANGELOG_ITEMS;
2121+
}
2122+
return Math.max(
2123+
MIN_CHANGELOG_ITEMS,
2124+
Math.floor(termHeight * CHANGELOG_HEIGHT_FACTOR) - CHANGELOG_HEADER_OVERHEAD
2125+
);
2126+
}
2127+
2128+
/**
2129+
* Format the changelog section as markdown for rendering.
2130+
*
2131+
* Re-serializes the filtered section markdown with category headings so
2132+
* that `renderMarkdown()` applies consistent heading/list styling.
2133+
* Clamps the rendered output to fit ~1.3x the terminal height.
2134+
*
2135+
* @param data - Upgrade result with changelog
2136+
* @returns Rendered changelog string, or empty string if no changelog
2137+
*/
2138+
function formatChangelog(data: UpgradeResult): string {
2139+
if (!data.changelog || data.changelog.sections.length === 0) {
2140+
return "";
2141+
}
2142+
2143+
const { changelog } = data;
2144+
const lines: string[] = ["", "### What's new", ""];
2145+
2146+
for (const section of changelog.sections) {
2147+
lines.push(CATEGORY_HEADINGS[section.category]);
2148+
lines.push(section.markdown);
2149+
}
2150+
2151+
if (changelog.truncated) {
2152+
const more = changelog.originalCount - changelog.totalItems;
2153+
lines.push(
2154+
`<muted>...and ${more} more changes — https://github.com/getsentry/cli/releases</muted>`
2155+
);
2156+
}
2157+
2158+
// Render through the markdown pipeline, then clamp to terminal height
2159+
const rendered = renderMarkdown(lines.join("\n"));
2160+
const renderedLines = rendered.split("\n");
2161+
const maxLines = getMaxChangelogLines();
2162+
2163+
if (renderedLines.length <= maxLines) {
2164+
return rendered;
2165+
}
2166+
2167+
// Truncate and add a "more" indicator
2168+
const clamped = renderedLines.slice(0, maxLines);
2169+
const remaining = changelog.originalCount - changelog.totalItems;
2170+
const moreText =
2171+
remaining > 0
2172+
? "...and more — https://github.com/getsentry/cli/releases"
2173+
: "...truncated — https://github.com/getsentry/cli/releases";
2174+
clamped.push(isPlainOutput() ? moreText : muted(moreText));
2175+
return clamped.join("\n");
2176+
}
2177+
20832178
/** Action descriptions for human-readable output */
20842179
const ACTION_DESCRIPTIONS: Record<UpgradeResult["action"], string> = {
20852180
upgraded: "Upgraded",
@@ -2159,7 +2254,15 @@ export function formatUpgradeResult(data: UpgradeResult): string {
21592254
}
21602255
}
21612256

2162-
return renderMarkdown(lines.join("\n"));
2257+
const result = renderMarkdown(lines.join("\n"));
2258+
2259+
// Append changelog if available
2260+
const changelogOutput = formatChangelog(data);
2261+
if (changelogOutput) {
2262+
return `${result}\n${changelogOutput}`;
2263+
}
2264+
2265+
return result;
21632266
}
21642267

21652268
// Dashboard formatters

0 commit comments

Comments
 (0)