feat: the update changelog covers every version you missed #61

Merged
keb merged 2 commits from feat/changelog-since into main 2026-10-13 20:25:28 +02:00
5 changed files with 95 additions and 19 deletions

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ func updateCmd(configPath string, args []string) error {
fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag) fmt.Printf("lazysecrets %s is the latest (%s)\n", current, r.Tag)
return nil return nil
} }
fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes())) fmt.Printf("lazysecrets %s is out (you have %s):\n\n%s\n\n", r.Tag, current, indent(r.Notes(current)))
if check { if check {
fmt.Println("lazysecrets update installs it.") fmt.Println("lazysecrets update installs it.")
return nil return nil
+1 -1
View File
@@ -151,7 +151,7 @@ published on GitHub and on git.kebcloud.no, and it asks them in that
order, the next when one doesn't answer within 5 seconds; order, the next when one doesn't answer within 5 seconds;
`updates.updateSources` lists others, such as a mirror of your own. Offline, or with `updates.updateSources` lists others, such as a mirror of your own. Offline, or with
every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or
`lazysecrets update --check`) shows its changelog. `lazysecrets update --check`) shows what changed since your version, across the last 10 releases.
Updating (`u` there, or `lazysecrets update`) downloads the release for Updating (`u` there, or `lazysecrets update`) downloads the release for
your OS and CPU from the first server that has it, checks it against your OS and CPU from the first server that has it, checks it against
+8 -1
View File
@@ -110,15 +110,22 @@ func (m *Model) update() tea.Cmd {
func (m *Model) showUpdate() { func (m *Model) showUpdate() {
r := *m.newer r := *m.newer
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")} lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
if notes := r.Notes(); notes != "" { if notes := r.Notes(m.version); notes != "" {
lines = append(lines, "") lines = append(lines, "")
for _, l := range strings.Split(notes, "\n") { for _, l := range strings.Split(notes, "\n") {
if h, ok := strings.CutPrefix(l, "### "); ok { if h, ok := strings.CutPrefix(l, "### "); ok {
l = m.st.Label.Render(h) l = m.st.Label.Render(h)
} else if h, ok := strings.CutPrefix(l, "## "); ok { // a release, when several are new
l = m.st.TitleFocused.Render(h)
} }
lines = append(lines, l) lines = append(lines, l)
} }
} }
// A popup doesn't scroll: several releases behind, the rest is on
// the release pages.
if most := max(6, m.h-12); len(lines) > most {
lines = append(lines[:most-1], m.st.Muted.Render("… and more on the release pages"))
}
opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.", opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.",
run: func(m *Model) tea.Cmd { return m.install(r) }}} run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" { if r.URL != "" {
+50 -12
View File
@@ -28,6 +28,7 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime" "runtime"
"slices"
"strings" "strings"
"time" "time"
@@ -54,17 +55,43 @@ const maxDownload = 200 << 20
// Release is a published release. // Release is a published release.
type Release struct { type Release struct {
Tag string `json:"tag_name"` Tag string `json:"tag_name"`
Body string `json:"body"` // the changelog section, then install notes // Body is the changelog section, then install notes. From Latest,
// it's the changelogs of the recent releases, each under a "## v…"
// heading, so someone a few versions behind sees all they missed.
Body string `json:"body"`
URL string `json:"html_url"` URL string `json:"html_url"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
} }
// Notes is the release's changelog: its body without the install notes // Notes is the changelog since the version current: of each release
// every release ends with. // newer than it (just this one for a dev build), without the install
func (r Release) Notes() string { // notes every release ends with.
notes, _, _ := strings.Cut(r.Body, "## Install") func (r Release) Notes(current string) string {
body, _, _ := strings.Cut(r.Body, "## Install")
var keep, headings []string
in := true // before any heading: a single release's notes
for _, l := range strings.Split(body, "\n") {
if tag, ok := strings.CutPrefix(l, "## "); ok && semver.IsValid(tag) {
in = Newer(current, tag) || tag == r.Tag
if in {
headings = append(headings, l)
}
}
if in {
keep = append(keep, l)
}
}
notes := strings.Join(keep, "\n")
if len(headings) == 1 { // the title names it
notes = strings.Replace(notes, headings[0], "", 1)
}
return PlainLinks(strings.TrimSpace(notes)) return PlainLinks(strings.TrimSpace(notes))
} }
// recentReleases is how many releases Latest reads the changelogs of.
const recentReleases = 10
// PlainLinks shows Markdown links as their text, for a terminal. // PlainLinks shows Markdown links as their text, for a terminal.
func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") } func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") }
@@ -128,18 +155,29 @@ func (c *Client) latest(ctx context.Context, repo string) (Release, error) {
} }
ctx, cancel := context.WithTimeout(ctx, latestTimeout) ctx, cancel := context.WithTimeout(ctx, latestTimeout)
defer cancel() defer cancel()
b, err := c.get(ctx, api+"/releases/latest", 1<<20) // The recent releases in one request (per_page on GitHub, limit on
// Gitea), for their changelogs.
b, err := c.get(ctx, fmt.Sprintf("%s/releases?per_page=%d&limit=%[2]d", api, recentReleases), 1<<20)
if err != nil { if err != nil {
return Release{}, err return Release{}, err
} }
var r Release var all []Release
if err := json.Unmarshal(b, &r); err != nil { if err := json.Unmarshal(b, &all); err != nil {
return Release{}, fmt.Errorf("reading the latest release from %s: %w", repo, err) return Release{}, fmt.Errorf("reading the releases from %s: %w", repo, err)
} }
if !semver.IsValid(r.Tag) { all = slices.DeleteFunc(all, func(r Release) bool { return r.Draft || r.Prerelease || !semver.IsValid(r.Tag) })
return Release{}, fmt.Errorf("latest release %q on %s isn't a version", r.Tag, repo) if len(all) == 0 {
return Release{}, fmt.Errorf("no releases on %s", repo)
} }
return r, nil slices.SortFunc(all, func(a, b Release) int { return semver.Compare(b.Tag, a.Tag) })
var notes strings.Builder
for _, r := range all {
section, _, _ := strings.Cut(r.Body, "## Install")
fmt.Fprintf(&notes, "## %s\n\n%s\n\n", r.Tag, strings.TrimSpace(section))
}
latest := all[0]
latest.Body = strings.TrimSpace(notes.String())
return latest, nil
} }
// Newer reports whether latest is newer than current. A build that isn't // Newer reports whether latest is newer than current. A build that isn't
+35 -4
View File
@@ -39,11 +39,42 @@ func TestNewer(t *testing.T) {
func TestNotes(t *testing.T) { func TestNotes(t *testing.T) {
r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"} r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"}
if got := r.Notes(); got != "### Added\n\n- Install with a one-liner (#7)" { if got := r.Notes("0.1.1"); got != "### Added\n\n- Install with a one-liner (#7)" {
t.Errorf("notes %q", got) t.Errorf("notes %q", got)
} }
} }
// Latest reads the recent releases, so the notes cover every version
// since the one running.
func TestNotesSince(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/keb/lazysecrets/releases" {
http.NotFound(w, r)
return
}
fmt.Fprint(w, `[
{"tag_name":"v0.4.0-rc1","body":"### Added\n\n- rc","prerelease":true},
{"tag_name":"v0.2.0","body":"### Added\n\n- two\n\n## Install\n\ncurl"},
{"tag_name":"v0.3.0","body":"### Fixed\n\n- three\n\n## Install\n\ncurl","html_url":"https://example.com/v0.3.0"},
{"tag_name":"v0.1.1","body":"### Added\n\n- one"},
{"tag_name":"v9.0.0","body":"draft","draft":true}]`)
}))
t.Cleanup(srv.Close)
r, err := (&Client{Servers: []string{srv.URL + "/keb/lazysecrets"}}).Latest(context.Background())
if err != nil || r.Tag != "v0.3.0" || r.URL != "https://example.com/v0.3.0" {
t.Fatalf("latest %+v %v", r, err)
}
for current, want := range map[string]string{
"0.1.1": "## v0.3.0\n\n### Fixed\n\n- three\n\n## v0.2.0\n\n### Added\n\n- two",
"0.2.0": "### Fixed\n\n- three",
"dev-2ba762c": "### Fixed\n\n- three",
} {
if got := r.Notes(current); got != want {
t.Errorf("notes since %s: %q, want %q", current, got, want)
}
}
}
func TestManaged(t *testing.T) { func TestManaged(t *testing.T) {
if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" { if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" {
t.Error("package managers not recognised") t.Error("package managers not recognised")
@@ -70,8 +101,8 @@ func server(t *testing.T, tamper bool) *httptest.Server {
sum[0] ^= 0xff sum[0] ^= 0xff
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases/latest", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/api/v1/repos/keb/lazysecrets/releases", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`) fmt.Fprint(w, `[{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}]`)
}) })
mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("/keb/lazysecrets/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name) fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
@@ -173,7 +204,7 @@ func TestInstallRefusesATamperedArchive(t *testing.T) {
func TestNotesShowLinksAsText(t *testing.T) { func TestNotesShowLinksAsText(t *testing.T) {
r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"} r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"}
if got, want := r.Notes(), "### Added\n\n- Search (#11) (65dc2d7)"; got != want { if got, want := r.Notes("0.1.1"), "### Added\n\n- Search (#11) (65dc2d7)"; got != want {
t.Errorf("notes %q, want %q", got, want) t.Errorf("notes %q, want %q", got, want)
} }
} }