From 5e10c1857bd2e61e1391615e308b049c59295c04 Mon Sep 17 00:00:00 2001 From: vmfunc Date: Mon, 8 Jun 2026 19:08:33 -0700 Subject: [PATCH] feat: show release notes via patch notes - `sif patchnote` (also `-pn`) fetches the latest github release and renders its notes with glamour - on the first run of a new version those notes are shown once, then recorded so they dont show again - best-effort, so dev builds, the SIF_NO_PATCHNOTES opt-out, and any network failure stay quiet - wire up `var version` so the release `-X main.version` ldflag actually lands, and add `sif version` --- cmd/sif/main.go | 22 ++++ go.mod | 2 +- internal/patchnotes/patchnotes.go | 143 +++++++++++++++++++++++++ internal/patchnotes/patchnotes_test.go | 42 ++++++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 internal/patchnotes/patchnotes.go create mode 100644 internal/patchnotes/patchnotes_test.go diff --git a/cmd/sif/main.go b/cmd/sif/main.go index 8f5fe95..724faf7 100644 --- a/cmd/sif/main.go +++ b/cmd/sif/main.go @@ -13,15 +13,33 @@ package main import ( + "fmt" + "os" + "github.com/charmbracelet/log" "github.com/dropalldatabases/sif" "github.com/dropalldatabases/sif/internal/config" + "github.com/dropalldatabases/sif/internal/patchnotes" // Register framework detectors _ "github.com/dropalldatabases/sif/internal/scan/frameworks/detectors" ) +// version is filled in at release time via -ldflags "-X main.version=...". +var version = "dev" + func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "patchnote", "patchnotes", "-pn", "--patchnotes": + patchnotes.Print("") + return + case "version", "-version", "--version": + fmt.Printf("sif %s\n", version) + return + } + } + settings := config.Parse() app, err := sif.New(settings) @@ -29,6 +47,10 @@ func main() { log.Fatal(err) } + if !settings.ApiMode { + patchnotes.ShowOnce(version) + } + err = app.Run() if err != nil { log.Fatal(err) diff --git a/go.mod b/go.mod index 26d9f6e..39bccf0 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.7 require ( github.com/antchfx/htmlquery v1.3.6 + github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/log v1.0.0 github.com/likexian/whois v1.15.7 @@ -91,7 +92,6 @@ require ( github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect - github.com/charmbracelet/glamour v0.10.0 // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250908092851-c2208eb08494 // indirect diff --git a/internal/patchnotes/patchnotes.go b/internal/patchnotes/patchnotes.go new file mode 100644 index 0000000..614acae --- /dev/null +++ b/internal/patchnotes/patchnotes.go @@ -0,0 +1,143 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +// Package patchnotes shows release notes pulled from the github releases. +package patchnotes + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/glamour" +) + +const releasesAPI = "https://api.github.com/repos/vmfunc/sif/releases" + +type release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + URL string `json:"html_url"` +} + +func fetch(ctx context.Context, path string) (*release, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesAPI+path, http.NoBody) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github returned %s", resp.Status) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) + if err != nil { + return nil, err + } + + var r release + if err := json.Unmarshal(body, &r); err != nil { + return nil, err + } + return &r, nil +} + +// render turns a release's markdown body into styled terminal output, falling +// back to the raw body if glamour can't render it. +func render(r *release) string { + out, err := glamour.Render(r.Body, "dark") + if err != nil { + return r.Body + } + return fmt.Sprintf("%s\n%s", r.TagName, out) +} + +// Print fetches the latest release and writes its notes to stdout. tag may be +// empty for the latest release, or a "vX" tag for a specific one. +func Print(tag string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + path := "/latest" + if tag != "" { + path = "/tags/" + tag + } + + r, err := fetch(ctx, path) + if err != nil { + fmt.Printf("couldn't fetch patch notes: %v\n", err) + return + } + fmt.Print(render(r)) +} + +// ShowOnce prints the running version's notes the first time that version runs, +// then records it so it isn't shown again. best-effort: dev builds, the +// SIF_NO_PATCHNOTES opt-out, and any network failure stay silent. +func ShowOnce(version string) { + if version == "" || version == "dev" || os.Getenv("SIF_NO_PATCHNOTES") != "" { + return + } + + path, err := statePath() + if err != nil || hasSeen(path, version) { + return + } + // record before fetching so a flaky network doesn't nag on every run + recordSeen(path, version) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + r, err := fetch(ctx, "/tags/v"+version) + if err != nil { + return + } + fmt.Printf("\nwhat's new in this release:\n%s", render(r)) +} + +func statePath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "sif", "seen_version"), nil +} + +func hasSeen(path, version string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + return strings.TrimSpace(string(data)) == version +} + +func recordSeen(path, version string) { + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return + } + _ = os.WriteFile(path, []byte(version), 0o600) +} diff --git a/internal/patchnotes/patchnotes_test.go b/internal/patchnotes/patchnotes_test.go new file mode 100644 index 0000000..537afbb --- /dev/null +++ b/internal/patchnotes/patchnotes_test.go @@ -0,0 +1,42 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2026 vmfunc, xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package patchnotes + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestSeenRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "sif", "seen_version") + + if hasSeen(path, "2026.6.7") { + t.Fatal("nothing recorded yet, hasSeen should be false") + } + + recordSeen(path, "2026.6.7") + if !hasSeen(path, "2026.6.7") { + t.Error("recorded version should read back as seen") + } + if hasSeen(path, "2026.6.8") { + t.Error("a different version should not be seen") + } +} + +func TestRenderIncludesTag(t *testing.T) { + out := render(&release{TagName: "v2026.6.7", Body: "## what's changed\n- a thing"}) + if !strings.Contains(out, "v2026.6.7") { + t.Errorf("rendered notes should include the tag, got %q", out) + } +}