mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
feat(x): aggregate release notes across skipped versions, persistent up-to-date status
- Updater aggregates release bodies for every version between the running app and the update target, with a commit-log fallback when no release in range has notes - CI fills empty release bodies with GitHub's auto-generated notes - Settings shows a persistent 'You're up to date' line with last-checked time instead of a transient confirmation
This commit is contained in:
parent
e2aac0a711
commit
35aa1178c5
3 changed files with 138 additions and 40 deletions
21
.github/workflows/electron-build.yml
vendored
21
.github/workflows/electron-build.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string | undefined> {
|
||||
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<string>();
|
||||
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<void> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -129,34 +129,15 @@ type UpdaterStatus = ipcShared.IPCChannels['updater:status']['req']
|
|||
|
||||
function UpdateSettings() {
|
||||
const [status, setStatus] = useState<UpdaterStatus | null>(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<number | null>(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 = (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="space-y-2">
|
||||
{/* 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 && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<span>
|
||||
{`You're up to date! Rowboat v${status.version} is the latest version.`}
|
||||
<span className="text-muted-foreground/60">
|
||||
{` Checked at ${new Date(status.lastCheckedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={checkNow}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Check for updates
|
||||
</Button>
|
||||
{confirmedUpToDate && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
||||
{"You're up to date."}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
break
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue