diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index e68ce4f4..53919231 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -8,6 +8,27 @@ permissions: contents: write # Required to upload release assets jobs: + # The in-app updater surfaces the release body as "What's new" — an empty + # body means users get an update with no notes at all. If the release was + # published without notes, fill it with GitHub's auto-generated ones + # (merged PRs / commits since the previous release). + release-notes: + runs-on: ubuntu-latest + steps: + - name: Fill empty release body with generated notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="${GITHUB_REF#refs/tags/}" + BODY="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" --jq '.body // ""')" + if [ -n "$BODY" ]; then + echo "Release ${TAG} already has notes; leaving them as-is." + exit 0 + fi + NOTES="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" -f tag_name="${TAG}" --jq .body)" + gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --notes "$NOTES" + echo "Filled ${TAG} with generated release notes." + build-macos: runs-on: macos-latest diff --git a/apps/x/apps/main/src/updater.ts b/apps/x/apps/main/src/updater.ts index bf274623..9251af8f 100644 --- a/apps/x/apps/main/src/updater.ts +++ b/apps/x/apps/main/src/updater.ts @@ -89,7 +89,10 @@ export function initUpdater(): void { releaseNotes: releaseNotes || undefined, }); showReadyBadge(); - if (!releaseNotes) void backfillReleaseNotes(releaseName || undefined); + // Always fetch the aggregated notes: even when Squirrel supplied a body, + // it covers only the target release — an update spanning several versions + // (0.6.0 → 0.7.1) would silently drop everything in between. + void backfillReleaseNotes(releaseName || undefined); }); autoUpdater.on("error", (err) => { setStatus({ state: "error", error: err.message, lastCheckedAt: status.lastCheckedAt }); @@ -105,28 +108,113 @@ export function initUpdater(): void { }); } +// Numeric x.y.z comparison; any prerelease suffix is ignored (release tags +// here are plain versions like v0.7.1). +function cmpVersions(a: string, b: string): number { + const pa = a.split("-")[0].split(".").map(Number); + const pb = b.split("-")[0].split(".").map(Number); + for (let i = 0; i < 3; i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d) return d; + } + return 0; +} + /** - * Fetch the GitHub release body for the staged update so the restart card - * can show "What's new" inline. Best-effort: on any failure the card simply - * omits the notes and keeps the link to the releases page. + * GitHub's release page falls back to the tagged commit's message when a + * release has no body — mirror that: list the commit subjects between the + * two version tags. Merge-PR subjects ("Merge pull request #689 from …") + * carry their meaning in the body, so those render as "token optimizations + * (#689)"; other merge commits are skipped as noise. + */ +async function commitLogFallback(fromVersion: string, toVersion: string): Promise { + const res = await net.fetch( + `https://api.github.com/repos/${REPO}/compare/v${fromVersion}...v${toVersion}`, + { headers: { Accept: "application/vnd.github+json", "User-Agent": "Rowboat" } }, + ); + if (!res.ok) return undefined; + const { commits } = (await res.json()) as { + commits?: Array<{ commit: { message: string } }>; + }; + if (!commits?.length) return undefined; + // Merge-based history lists both a PR's merge commit and its member + // commits — dedupe on the text minus any "(#N)" suffix. The merge commit + // is the newer one, so after reversing, the PR-numbered line wins. + const seen = new Set(); + const lines = commits + .map(({ commit }) => { + const [subject, ...rest] = commit.message.split("\n"); + const pr = subject.match(/^Merge pull request (#\d+) from /); + if (pr) { + const body = rest.map((l) => l.trim()).find(Boolean); + return body ? `${body} (${pr[1]})` : undefined; + } + return subject.startsWith("Merge ") ? undefined : subject.trim(); + }) + .filter((s): s is string => Boolean(s)) + .reverse() // compare lists oldest→newest; show newest first + .filter((line) => { + const key = line.replace(/ \(#\d+\)$/, "").toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + if (!lines.length) return undefined; + const MAX = 30; + const shown = lines.slice(0, MAX).map((l) => `- ${l}`); + if (lines.length > MAX) shown.push(`- …and ${lines.length - MAX} more`); + return shown.join("\n"); +} + +/** + * Fetch release notes for the staged update so the restart card can show + * "What's new" inline. Aggregates the bodies of EVERY release between the + * running version (exclusive) and the update target (inclusive) — an update + * that skips versions shows the changes of all of them, and releases with + * empty bodies are simply omitted. If NO release in range has a body, falls + * back to the commit log between the two versions. Best-effort: on any + * failure the card keeps whatever Squirrel supplied plus the link to the + * releases page. */ async function backfillReleaseNotes(releaseName: string | undefined): Promise { - const url = releaseName - ? `https://api.github.com/repos/${REPO}/releases/tags/${releaseName.startsWith("v") ? releaseName : `v${releaseName}`}` - : `https://api.github.com/repos/${REPO}/releases/latest`; try { - const res = await net.fetch(url, { + const res = await net.fetch(`https://api.github.com/repos/${REPO}/releases?per_page=30`, { headers: { Accept: "application/vnd.github+json", "User-Agent": "Rowboat" }, }); if (!res.ok) return; - const release = (await res.json()) as { tag_name?: string; body?: string }; - // A newer download may have re-staged meanwhile — only fill in gaps. - if (status.state !== "ready" || status.releaseNotes) return; - if (!release.body) return; + const releases = (await res.json()) as Array<{ + tag_name?: string; + body?: string; + draft?: boolean; + prerelease?: boolean; + }>; + // The update service only serves stable releases, so absent a name from + // Squirrel the target is the newest non-prerelease. + const target = ( + releaseName ?? releases.find((r) => !r.draft && !r.prerelease)?.tag_name ?? "" + ).replace(/^v/, ""); + if (!target) return; + const inRange = releases + .filter((r) => { + if (r.draft || !r.tag_name) return false; + const v = r.tag_name.replace(/^v/, ""); + return cmpVersions(v, status.version) > 0 && cmpVersions(v, target) <= 0; + }) + .sort((a, b) => cmpVersions(b.tag_name!.replace(/^v/, ""), a.tag_name!.replace(/^v/, ""))); + const withNotes = inRange.filter((r) => r.body?.trim()); + // A single-step update reads better without a version heading (the card + // already shows the version badge); multi-step gets one per release. + const notes = + withNotes.length === 1 + ? withNotes[0].body!.trim() + : withNotes.map((r) => `## ${r.tag_name}\n\n${r.body!.trim()}`).join("\n\n"); + const finalNotes = notes || (await commitLogFallback(status.version, target)); + // A newer download may have re-staged meanwhile — don't stomp its state. + if (status.state !== "ready") return; setStatus({ state: "ready", - newVersion: status.newVersion ?? release.tag_name, - releaseNotes: release.body, + newVersion: status.newVersion ?? target, + releaseNotes: finalNotes || status.releaseNotes, }); } catch (err) { console.error("[Updater] release notes fetch failed:", err); diff --git a/apps/x/apps/renderer/src/components/settings-dialog.tsx b/apps/x/apps/renderer/src/components/settings-dialog.tsx index 8a263a10..9c5ada79 100644 --- a/apps/x/apps/renderer/src/components/settings-dialog.tsx +++ b/apps/x/apps/renderer/src/components/settings-dialog.tsx @@ -129,34 +129,15 @@ type UpdaterStatus = ipcShared.IPCChannels['updater:status']['req'] function UpdateSettings() { const [status, setStatus] = useState(null) - // When the user clicked "Check for updates" — the "You're up to date" - // confirmation only shows for a check completed after this, so a stale - // lastCheckedAt from a background check never reads as click feedback. - const [checkStartedAt, setCheckStartedAt] = useState(null) useEffect(() => { void window.ipc.invoke('updater:getStatus', null).then(setStatus) return window.ipc.on('updater:status', setStatus) }, []) - const confirmedUpToDate = - checkStartedAt !== null && - status?.state === 'idle' && - status.lastCheckedAt !== undefined && - status.lastCheckedAt >= checkStartedAt - - // The confirmation is click feedback, not a status — fade it after a bit - // rather than leaving a stale "You're up to date" up indefinitely. - useEffect(() => { - if (!confirmedUpToDate) return - const timer = setTimeout(() => setCheckStartedAt(null), 5000) - return () => clearTimeout(timer) - }, [confirmedUpToDate]) - if (!status) return null const checkNow = () => { - setCheckStartedAt(Date.now()) // Progress arrives via updater:status pushes; using the invoke's snapshot // here could stomp a newer pushed state. void window.ipc.invoke('updater:check', null) @@ -233,17 +214,25 @@ function UpdateSettings() { break case 'idle': body = ( -
+
+ {/* lastCheckedAt only exists after a check that found no update + (an available update moves the state to downloading/ready), so + idle + lastCheckedAt genuinely means "on the latest version". */} + {status.lastCheckedAt !== undefined && ( +

+ + + {`You're up to date! Rowboat v${status.version} is the latest version.`} + + {` Checked at ${new Date(status.lastCheckedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`} + + +

+ )} - {confirmedUpToDate && ( - - - {"You're up to date."} - - )}
) break