Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions go/internal/selfupdate/selfupdate.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ type Info struct {
Latest string `json:"latest,omitempty"`
PublishedAt time.Time `json:"published_at,omitempty"`
ReleaseNotesURL string `json:"release_notes_url,omitempty"`
// ReleaseBody is the markdown body of the GitHub release —
// typically the auto-generated changelog section (Features, Bug
// Fixes). The UI renders this inline in the update modal so
// operators can read what's about to be applied without opening
// a new tab. Capped at MaxReleaseBodyBytes to keep a pathological
// release note from ballooning the Info payload.
ReleaseBody string `json:"release_body,omitempty"`
CheckedAt time.Time `json:"checked_at,omitempty"`
UpdateAvailable bool `json:"update_available"`
Skipped bool `json:"skipped"`
Expand All @@ -80,6 +87,12 @@ type Info struct {
SidecarReady bool `json:"sidecar_ready"`
}

// MaxReleaseBodyBytes caps the persisted release body. 16 KiB covers a
// few dozen bullets from semantic-release comfortably; anything larger
// is truncated with a trailing marker and the operator keeps the
// ReleaseNotesURL link for the full thing.
const MaxReleaseBodyBytes = 16 * 1024

// UpdateStatus mirrors the sidecar's state.json so handlers can pass it
// through unchanged.
type UpdateStatus struct {
Expand Down Expand Up @@ -190,6 +203,7 @@ func (c *Checker) Check(ctx context.Context, force bool) (Info, error) {
var rel struct {
TagName string `json:"tag_name"`
HtmlURL string `json:"html_url"`
Body string `json:"body"`
PublishedAt time.Time `json:"published_at"`
Prerelease bool `json:"prerelease"`
Draft bool `json:"draft"`
Expand All @@ -206,6 +220,7 @@ func (c *Checker) Check(ctx context.Context, force bool) (Info, error) {
c.info.Latest = rel.TagName
c.info.PublishedAt = rel.PublishedAt
c.info.ReleaseNotesURL = rel.HtmlURL
c.info.ReleaseBody = truncateBody(rel.Body)
c.info.UpdateAvailable = isNewer(rel.TagName, c.info.Current)
}
c.info.CheckedAt = c.cfg.Now()
Expand Down Expand Up @@ -405,3 +420,15 @@ func parseSemver(s string) *[3]int {
}
return &out
}

// truncateBody caps release-body markdown to MaxReleaseBodyBytes so a
// runaway release note (auto-generated from hundreds of commits on a
// long-lived branch) can't inflate /api/version/check payloads. When we
// cut, we leave a clear marker so the UI can point the operator at
// ReleaseNotesURL for the rest.
func truncateBody(b string) string {
if len(b) <= MaxReleaseBodyBytes {
return b
}
return b[:MaxReleaseBodyBytes] + "\n\n…(truncated — see release notes for full changelog)"
}
45 changes: 45 additions & 0 deletions go/internal/selfupdate/selfupdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
Expand Down Expand Up @@ -35,12 +36,18 @@ func (s *memStore) LoadConfig(k string) (string, bool) {
}

func fakeGHServer(t *testing.T, tag, url string, published time.Time) *httptest.Server {
t.Helper()
return fakeGHServerWithBody(t, tag, url, published, "")
}

func fakeGHServerWithBody(t *testing.T, tag, url string, published time.Time, body string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"tag_name": tag,
"html_url": url,
"published_at": published.Format(time.RFC3339),
"body": body,
})
}))
}
Expand Down Expand Up @@ -266,6 +273,44 @@ func TestIsNewer(t *testing.T) {
}
}

func TestCheck_CapturesReleaseBody(t *testing.T) {
published := time.Date(2026, 4, 20, 10, 0, 0, 0, time.UTC)
body := "### Bug Fixes\n\n* **pvmodel:** drop intercept ([#136](https://example/pr/136))"
srv := fakeGHServerWithBody(t, "v1.5.0", "https://example/rel/1.5.0", published, body)
defer srv.Close()

c := New(Config{CurrentVersion: "v1.4.0", ReleasesURL: srv.URL}, newMemStore())
info, err := c.Check(context.Background(), false)
if err != nil {
t.Fatalf("Check: %v", err)
}
if info.ReleaseBody != body {
t.Errorf("ReleaseBody = %q, want %q", info.ReleaseBody, body)
}
}

func TestCheck_TruncatesHugeReleaseBody(t *testing.T) {
// Build a body larger than the cap so we hit the truncation branch.
huge := strings.Repeat("* entry\n", (MaxReleaseBodyBytes/8)+200)
if len(huge) <= MaxReleaseBodyBytes {
t.Fatalf("test fixture too small: %d bytes", len(huge))
}
srv := fakeGHServerWithBody(t, "v2.0.0", "", time.Now(), huge)
defer srv.Close()

c := New(Config{CurrentVersion: "v1.0.0", ReleasesURL: srv.URL}, newMemStore())
info, err := c.Check(context.Background(), false)
if err != nil {
t.Fatalf("Check: %v", err)
}
if len(info.ReleaseBody) < MaxReleaseBodyBytes || len(info.ReleaseBody) > MaxReleaseBodyBytes+200 {
t.Errorf("ReleaseBody length = %d, expected near %d+truncation marker", len(info.ReleaseBody), MaxReleaseBodyBytes)
}
if !strings.Contains(info.ReleaseBody, "truncated") {
t.Error("truncated body should carry the truncation marker")
}
}

func TestTrigger_ValidatesAction(t *testing.T) {
c := New(Config{SocketPath: "/tmp/notreal.sock"}, newMemStore())
if err := c.Trigger(context.Background(), "delete-everything", ""); err == nil {
Expand Down
166 changes: 159 additions & 7 deletions web/update-badge.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,20 @@
`;

const notesHref = safeHref(info.release_notes_url);
const notes = hasUpdate && notesHref
? `<a class="notes" href="${escapeHTML(notesHref)}" target="_blank" rel="noopener">Release notes ↗</a>`
const notesLink = hasUpdate && notesHref
? `<a class="notes-link" href="${escapeHTML(notesHref)}" target="_blank" rel="noopener">Open on GitHub ↗</a>`
: "";
// Render the release body inline so the operator can read what's
// about to be applied without opening a tab. Markdown is a small
// subset (headings, lists, code, strong, safe links) — anything
// else stays as plain escaped text. See renderReleaseBody.
const bodyHTML = hasUpdate && info.release_body
? `<details class="changelog" open>
<summary>What's in ${escapeHTML(info.latest || "this release")}</summary>
<div class="changelog-body">${renderReleaseBody(info.release_body)}</div>
${notesLink ? `<p class="changelog-link">${notesLink}</p>` : ""}
</details>`
: (hasUpdate && notesLink ? `<p class="changelog-link">${notesLink}</p>` : "");

return `
<div class="backdrop" data-action="close"></div>
Expand All @@ -244,7 +255,7 @@
${info.latest ? `<div><dt>Latest</dt><dd>${escapeHTML(info.latest)}</dd></div>` : ""}
${info.skipped_version ? `<div><dt>Skipped</dt><dd>${escapeHTML(info.skipped_version)}</dd></div>` : ""}
</dl>
${notes}
${bodyHTML}
${info.err ? `<p class="err">Last check failed: ${escapeHTML(info.err)}</p>` : ""}
</div>
<footer>${actions}</footer>
Expand Down Expand Up @@ -385,12 +396,73 @@
dl > div { display: contents; }
dt { color: var(--text-dim, #94a3b8); font-size: 0.8rem; }
dd { margin: 0; font-variant-numeric: tabular-nums; }
.notes {
display: inline-block; margin-top: 0.75rem;
.changelog {
margin-top: 0.75rem;
border: 1px solid var(--border, #334155);
border-radius: 4px;
background: rgba(255,255,255,0.02);
}
.changelog > summary {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
color: var(--text-dim, #94a3b8);
list-style: none;
}
.changelog > summary::-webkit-details-marker { display: none; }
.changelog > summary::before {
content: "▸";
display: inline-block;
margin-right: 0.4rem;
transition: transform 0.15s;
}
.changelog[open] > summary::before { transform: rotate(90deg); }
.changelog-body {
padding: 0.25rem 0.9rem 0.5rem;
max-height: 40vh;
overflow-y: auto;
font-size: 0.85rem;
line-height: 1.45;
}
.changelog-body h4 {
margin: 0.75rem 0 0.3rem;
font-size: 0.9rem;
color: var(--text, #e2e8f0);
}
.changelog-body h5 {
margin: 0.6rem 0 0.25rem;
font-size: 0.8rem;
color: var(--text-dim, #94a3b8);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.changelog-body ul {
margin: 0.25rem 0 0.25rem;
padding-left: 1.1rem;
}
.changelog-body li { margin-bottom: 0.2rem; }
.changelog-body p { margin: 0.35rem 0; }
.changelog-body code {
background: rgba(255,255,255,0.08);
padding: 0.05rem 0.25rem;
border-radius: 3px;
font-size: 0.82rem;
}
.changelog-body a {
color: var(--accent, #f59e0b);
text-decoration: none; font-size: 0.85rem;
text-decoration: none;
}
.notes:hover { text-decoration: underline; }
.changelog-body a:hover { text-decoration: underline; }
.changelog-link {
margin: 0.4rem 0.9rem 0.6rem;
font-size: 0.8rem;
}
.notes-link {
color: var(--accent, #f59e0b);
text-decoration: none;
}
.notes-link:hover { text-decoration: underline; }
.err {
margin-top: 0.75rem;
color: #f87171; font-size: 0.85rem;
Expand Down Expand Up @@ -473,5 +545,85 @@
.replace(/'/g, "&#39;");
}

// renderReleaseBody turns GitHub-flavored markdown (as emitted by
// semantic-release: headings, bullet lists, links, `code`, **bold**)
// into a safe HTML subset. Strategy: escape everything first, then
// rewrite a short whitelist of markdown tokens. Untrusted content —
// link URLs — is routed through safeHref so a `javascript:` href
// can't sneak in.
//
// What we handle (enough for conventional-commits changelogs):
// ##, ### → h4, h5
// - x / * x → unordered list (adjacent bullets grouped)
// **bold** → <strong>
// `code` → <code>
// [text](url) → <a href=...> (url filtered)
// blank line → paragraph break
//
// What we deliberately drop: images, tables, raw HTML, setext
// headings, nested lists, numbered lists. They're rare in release
// notes and the operator still has the "Open on GitHub ↗" link for
// the full formatted version.
function renderReleaseBody(md) {
const escaped = escapeHTML(String(md || "").trim());
const lines = escaped.split(/\r?\n/);
const out = [];
let inList = false;
const flushList = () => {
if (inList) {
out.push("</ul>");
inList = false;
}
};
for (let raw of lines) {
const line = raw.replace(/\s+$/, "");
if (!line) {
flushList();
continue;
}
// Bullet: "- text" or "* text" (leading spaces tolerated for
// semantic-release which indents scope details).
const bullet = line.match(/^\s*[*-]\s+(.*)$/);
if (bullet) {
if (!inList) {
out.push("<ul>");
inList = true;
}
out.push("<li>" + renderInline(bullet[1]) + "</li>");
continue;
}
flushList();
// Headings
const h3 = line.match(/^###\s+(.*)$/);
if (h3) { out.push("<h5>" + renderInline(h3[1]) + "</h5>"); continue; }
const h2 = line.match(/^##\s+(.*)$/);
if (h2) { out.push("<h4>" + renderInline(h2[1]) + "</h4>"); continue; }
// Paragraph fallback.
out.push("<p>" + renderInline(line) + "</p>");
}
flushList();
return out.join("");
}

// renderInline handles **bold**, `code`, and [text](url) on an
// already-HTML-escaped line. Order matters: code first so backticks
// can't eat a `**bold**` marker that happened to be inside code.
function renderInline(s) {
// Inline code: backticks are already literal in the escaped text.
s = s.replace(/`([^`]+)`/g, (_m, code) => "<code>" + code + "</code>");
// Bold: **text**
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// Links: [text](url). The URL has been HTML-escaped already (amp →
// &amp;), so decode just the &amp; inside the href before running
// safeHref — otherwise a legitimate query-string URL gets rejected.
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, url) => {
const clean = String(url).replace(/&amp;/g, "&");
const safe = safeHref(clean);
if (!safe) return text; // drop the link, keep the visible text
return '<a href="' + escapeHTML(safe) + '" target="_blank" rel="noopener">' + text + "</a>";
});
return s;
}

customElements.define("ftw-update-badge", FtwUpdateBadge);
})();
Loading