Skip to content

Commit a15aaa3

Browse files
Ehco1996claude
andcommitted
fix(updater): use commit SHA, not BuildTime, to detect republish
published_at is always slightly later than BuildTime for the same artifact (goreleaser publishes the release seconds after building), so the time-based comparison always fired -> false "update available" for the very release the user just installed. Switch to comparing constant.GitRevision against the commit SHA the release tag points at (GET /commits/{tag}). Bare `go build` (no revision injected) and GitHub fetch failures stay conservative -> no spurious prompts; user can --force. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1e0e74c commit a15aaa3

4 files changed

Lines changed: 60 additions & 63 deletions

File tree

internal/cli/update.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ var UpdateCMD = &cli.Command{
2424
Channel: c.String("channel"),
2525
Force: c.Bool("force"),
2626
Restart: !c.Bool("no-restart"),
27-
}, constant.Version, constant.BuildTime, cliLogger, nil)
27+
}, constant.Version, constant.GitRevision, cliLogger, nil)
2828
},
2929
}

internal/updater/updater.go

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,9 @@ type ghRelease struct {
8080
}
8181

8282
// Check resolves channel against currentVersion and queries GitHub.
83-
// currentBuildTime is the ldflag-injected constant.BuildTime; empty is fine.
84-
func Check(ctx context.Context, channel, currentVersion, currentBuildTime string) (*CheckResult, error) {
83+
// currentRevision is the ldflag-injected constant.GitRevision; empty is
84+
// tolerated (we'll just trust version-string equality in that case).
85+
func Check(ctx context.Context, channel, currentVersion, currentRevision string) (*CheckResult, error) {
8586
resolved, rel, err := pickRelease(ctx, channel, currentVersion)
8687
if err != nil {
8788
return nil, err
@@ -98,7 +99,7 @@ func Check(ctx context.Context, channel, currentVersion, currentBuildTime string
9899
PublishedAt: rel.PublishedAt,
99100
}
100101
if latest == currentVersion {
101-
res.UpdateAvailable = nightlyRepublished(rel, currentBuildTime)
102+
res.UpdateAvailable = nightlyRepublished(ctx, rel, currentRevision)
102103
} else {
103104
res.UpdateAvailable = compareVersions(latest, currentVersion) > 0
104105
}
@@ -111,7 +112,7 @@ func Check(ctx context.Context, channel, currentVersion, currentBuildTime string
111112

112113
// Apply downloads + swaps + (optionally) restarts. Each phase is reported
113114
// to onState so the dashboard can render progress; CLI passes nil.
114-
func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentBuildTime string, log *zap.SugaredLogger, onState func(State)) error {
115+
func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentRevision string, log *zap.SugaredLogger, onState func(State)) error {
115116
emit := func(s State) {
116117
if onState != nil {
117118
onState(s)
@@ -128,9 +129,9 @@ func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentBuildT
128129

129130
if !opts.Force {
130131
if latest == currentVersion {
131-
if nightlyRepublished(rel, currentBuildTime) {
132-
log.Infof("nightly tag %s republished after local build (%s); reinstalling",
133-
rel.TagName, currentBuildTime)
132+
if nightlyRepublished(ctx, rel, currentRevision) {
133+
log.Infof("nightly tag %s now points at a different commit than local %s; reinstalling",
134+
rel.TagName, currentRevision)
134135
} else {
135136
log.Info("already up to date")
136137
emit(StateDone)
@@ -269,30 +270,49 @@ func getJSON(ctx context.Context, url string, out any) error {
269270
}
270271

271272
// nightlyRepublished reports whether a release whose tag matches the
272-
// running version is actually newer than the current binary. Nightly
273-
// uses a rolling tag (v1.1.7-next), so version-string equality alone
274-
// would mask republished builds. Only meaningful for prereleases; stable
275-
// tags don't roll.
276-
func nightlyRepublished(rel *ghRelease, currentBuildTime string) bool {
277-
if !rel.Prerelease || currentBuildTime == "" {
273+
// running version actually points at a different commit than the
274+
// running binary. Nightly uses a rolling tag (v1.1.7-next), so
275+
// version-string equality alone would mask republished builds.
276+
//
277+
// We can't time-compare published_at vs BuildTime: goreleaser publishes
278+
// the release a few seconds after building the artifact, so the same
279+
// artifact would always look "older" than its own release. SHA is the
280+
// only reliable signal.
281+
//
282+
// Conservative on uncertainty: empty currentRevision (bare `go build`)
283+
// or GitHub fetch failure -> false (no spurious update prompts; user
284+
// can --force).
285+
func nightlyRepublished(ctx context.Context, rel *ghRelease, currentRevision string) bool {
286+
if !rel.Prerelease || currentRevision == "" {
278287
return false
279288
}
280-
built, ok := parseBuildTime(currentBuildTime)
281-
if !ok {
289+
sha, err := fetchTagCommitSHA(ctx, rel.TagName)
290+
if err != nil || sha == "" {
282291
return false
283292
}
284-
return rel.PublishedAt.After(built)
293+
return !shaMatchesRevision(sha, currentRevision)
285294
}
286295

287-
// parseBuildTime accepts both ldflag formats: goreleaser's RFC3339
288-
// ({{.Date}}) and the Makefile's "2006-01-02-15:04:05".
289-
func parseBuildTime(s string) (time.Time, bool) {
290-
for _, layout := range []string{time.RFC3339, "2006-01-02-15:04:05"} {
291-
if t, err := time.Parse(layout, s); err == nil {
292-
return t, true
293-
}
296+
// shaMatchesRevision compares the short revision baked into the binary
297+
// (goreleaser uses {{.ShortCommit}}, 7 chars; Makefile uses full SHA)
298+
// against the full SHA returned by GitHub. Prefix-match is enough.
299+
func shaMatchesRevision(fullSHA, currentRevision string) bool {
300+
n := len(currentRevision)
301+
if n == 0 || n > len(fullSHA) {
302+
return false
303+
}
304+
return strings.EqualFold(fullSHA[:n], currentRevision)
305+
}
306+
307+
func fetchTagCommitSHA(ctx context.Context, tag string) (string, error) {
308+
var c struct {
309+
SHA string `json:"sha"`
310+
}
311+
url := "https://api.github.com/repos/Ehco1996/ehco/commits/" + tag
312+
if err := getJSON(ctx, url, &c); err != nil {
313+
return "", err
294314
}
295-
return time.Time{}, false
315+
return c.SHA, nil
296316
}
297317

298318
// compareVersions returns -1/0/1 like semver.Compare. Falls back to

internal/updater/updater_test.go

Lines changed: 13 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package updater
22

33
import (
44
"testing"
5-
"time"
65
)
76

87
func TestResolveChannel(t *testing.T) {
@@ -48,45 +47,23 @@ func TestCompareVersions(t *testing.T) {
4847
}
4948
}
5049

51-
func TestParseBuildTime(t *testing.T) {
50+
func TestShaMatchesRevision(t *testing.T) {
51+
full := "1e0e74cabcdef0123456789abcdef0123456789a"
5252
cases := []struct {
53-
in string
54-
want bool
53+
fullSHA, revision string
54+
want bool
5555
}{
56-
{"2026-05-04T23:10:16Z", true}, // goreleaser
57-
{"2026-05-04T23:10:16+08:00", true}, // RFC3339 w/ offset
58-
{"2026-05-04-23:10:16", true}, // Makefile
59-
{"", false},
60-
{"not-a-time", false},
56+
{full, "1e0e74c", true}, // goreleaser short
57+
{full, "1E0E74C", true}, // case-insensitive
58+
{full, full, true}, // Makefile full SHA
59+
{full, "deadbee", false}, // different commit
60+
{full, "", false}, // empty -> false (caller already guards)
61+
{full, full + "x", false}, // longer than full SHA
62+
{"short", "shortish", false}, // revision longer than fullSHA
6163
}
6264
for _, c := range cases {
63-
_, ok := parseBuildTime(c.in)
64-
if ok != c.want {
65-
t.Errorf("parseBuildTime(%q) ok=%v want %v", c.in, ok, c.want)
66-
}
67-
}
68-
}
69-
70-
func TestNightlyRepublished(t *testing.T) {
71-
built := "2026-05-04T23:10:16Z"
72-
older := time.Date(2026, 5, 4, 22, 0, 0, 0, time.UTC)
73-
newer := time.Date(2026, 5, 5, 6, 0, 0, 0, time.UTC)
74-
75-
cases := []struct {
76-
name string
77-
rel ghRelease
78-
buildTime string
79-
want bool
80-
}{
81-
{"prerelease republished after build", ghRelease{Prerelease: true, PublishedAt: newer}, built, true},
82-
{"prerelease same/older than build", ghRelease{Prerelease: true, PublishedAt: older}, built, false},
83-
{"stable release ignored", ghRelease{Prerelease: false, PublishedAt: newer}, built, false},
84-
{"empty build time -> conservative false", ghRelease{Prerelease: true, PublishedAt: newer}, "", false},
85-
{"unparseable build time -> false", ghRelease{Prerelease: true, PublishedAt: newer}, "garbage", false},
86-
}
87-
for _, c := range cases {
88-
if got := nightlyRepublished(&c.rel, c.buildTime); got != c.want {
89-
t.Errorf("%s: got %v want %v", c.name, got, c.want)
65+
if got := shaMatchesRevision(c.fullSHA, c.revision); got != c.want {
66+
t.Errorf("shaMatchesRevision(%q,%q)=%v want %v", c.fullSHA, c.revision, got, c.want)
9067
}
9168
}
9269
}

internal/web/handler_update.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func (s *Server) UpdateCheck(c echo.Context) error {
5454
}
5555
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
5656
defer cancel()
57-
res, err := updater.Check(ctx, channel, constant.Version, constant.BuildTime)
57+
res, err := updater.Check(ctx, channel, constant.Version, constant.GitRevision)
5858
if err != nil {
5959
return echo.NewHTTPError(http.StatusBadGateway, err.Error())
6060
}
@@ -105,7 +105,7 @@ func (s *Server) runUpdate(opts updater.ApplyOptions, job *JobStatus) {
105105
*job = next
106106
}
107107

108-
if err := updater.Apply(ctx, opts, constant.Version, constant.BuildTime, s.l, onState); err != nil {
108+
if err := updater.Apply(ctx, opts, constant.Version, constant.GitRevision, s.l, onState); err != nil {
109109
next := *job
110110
next.State = updater.StateFailed
111111
next.Error = err.Error()

0 commit comments

Comments
 (0)