Skip to content

Commit 676085a

Browse files
frahlgclaude
andauthored
fix(release): a newer beta no longer blocks promoting a tested beta (#1897)
* ci(release): a newer beta no longer blocks promoting a tested beta The native release-order check refused a stable whenever any newer beta was published. Betas ship several times a day, so a candidate that ran on sites for a week could never be promoted. A stable is now refused only when a newer stable exists; betas keep the old rule. AGENTS.md says so and describes the real hotfix path: the workflow runs only from master, so a fix ships as the next beta and promotes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(selfupdate): beta boxes take the highest release after a late stable A stable may now be promoted after newer betas ship, so GitHub lists it above a higher beta. resolveChannel took the first match, so every beta box got the stable and skipped the newest beta until the next beta shipped. The walk now keeps the higher of the first stable and the first beta, and stops at the first beta. The release-order check refuses any beta below a published release, so nothing listed after it can rank higher. Stable boxes still stop at the first stable. Tests: the reviewer's ordering fails on the old code and passes now, a paged list stops at the first beta, and the Python checker pins the rules the walk relies on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4e22b10 commit 676085a

6 files changed

Lines changed: 203 additions & 16 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ftw": patch
3+
---
4+
5+
A box on the beta channel now updates to the highest release, even when a stable is promoted after newer betas have shipped. Before, it took whichever release GitHub listed first, so a late stable hid the newest beta until the next beta shipped.

‎AGENTS.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,21 @@ Native 0.x path:
200200
week, with no open `release-blocker`.
201201
4. Dispatch the same workflow for `v0.X.Y` stable, naming the tested beta.
202202
The workflow checks the source commit and release assets, and keeps GitHub
203-
`releases/latest` on the old 2.x line.
203+
`releases/latest` on the old 2.x line. Newer betas may ship while the
204+
sites test the candidate; the workflow refuses a stable only when a newer
205+
stable exists. Beta boxes still take the highest release, so a late stable
206+
does not hide a newer beta.
204207
5. Promote the signed driver channel for installs that read `drivers-stable`:
205208
`gh workflow run ftw-drivers-release.yml -R srcfl/device-drivers --ref main -f channel=stable`.
206209
It promotes the driver commit already published as `drivers-beta`. If it
207210
refuses because `main` has moved past `drivers-beta`, publish beta first
208211
(`-f channel=beta`), then promote.
209212

213+
The workflow runs only from `master`, and a beta tag must match
214+
`package.json` there. So there is no patch branch for an older line: fix a
215+
stable on `master`, publish the next beta and promote it by the steps above.
216+
The site check starts again with that beta.
217+
210218
The old lines are retired: no further 2.x or 3.x releases, including safety
211219
hotfixes or betas. All new Core releases use the new 0.x path. Do not recommend
212220
3.x beta as an install or intermediate upgrade. Old clients and aliases may
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package selfupdate
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/http/httptest"
9+
"strconv"
10+
"sync/atomic"
11+
"testing"
12+
)
13+
14+
// A stable may be promoted after newer betas ship, so GitHub lists it above
15+
// a higher beta. Beta boxes must still get the highest version.
16+
func TestCheck_BetaPrefersNewerBetaListedBelowPromotedStable(t *testing.T) {
17+
releases := fakeReleasesServer(t, nil,
18+
fakeRelease{tag: "v0.158.0"},
19+
fakeRelease{tag: "v0.159.0-beta.1", prerelease: true},
20+
fakeRelease{tag: "v0.158.0-beta.3", prerelease: true},
21+
)
22+
for _, tc := range []struct {
23+
current string
24+
update bool
25+
}{
26+
{"v0.157.2-beta.1", true},
27+
{"v0.158.0-beta.3", true},
28+
{"v0.159.0-beta.1", false},
29+
} {
30+
t.Run(tc.current, func(t *testing.T) {
31+
c := newCheckerOnFakes(t, tc.current, releases, newMemStore())
32+
if err := c.SetChannel(ChannelBeta); err != nil {
33+
t.Fatalf("SetChannel: %v", err)
34+
}
35+
info, err := c.Check(context.Background(), true)
36+
if err != nil {
37+
t.Fatalf("Check: %v", err)
38+
}
39+
if info.Latest != "v0.159.0-beta.1" || info.UpdateAvailable != tc.update {
40+
t.Fatalf("latest=%q update=%v, want v0.159.0-beta.1 update=%v", info.Latest, info.UpdateAvailable, tc.update)
41+
}
42+
})
43+
}
44+
45+
stable := newCheckerOnFakes(t, "v0.157.2", releases, newMemStore())
46+
info, err := stable.Check(context.Background(), true)
47+
if err != nil {
48+
t.Fatalf("stable Check: %v", err)
49+
}
50+
if info.Channel != ChannelStable || info.Latest != "v0.158.0" || !info.UpdateAvailable {
51+
t.Fatalf("stable info = %+v", info)
52+
}
53+
}
54+
55+
// pagedReleasesServer serves one page per request and links each page to
56+
// the next, as GitHub does.
57+
func pagedReleasesServer(t *testing.T, calls *atomic.Int32, pages ...[]fakeRelease) *httptest.Server {
58+
t.Helper()
59+
var srv *httptest.Server
60+
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61+
calls.Add(1)
62+
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
63+
if page+1 < len(pages) {
64+
w.Header().Set("Link", fmt.Sprintf(`<%s/releases?page=%d>; rel="next"`, srv.URL, page+1))
65+
}
66+
list := make([]map[string]any, 0, len(pages[page]))
67+
for _, rel := range pages[page] {
68+
list = append(list, rel.json())
69+
}
70+
_ = json.NewEncoder(w).Encode(list)
71+
}))
72+
t.Cleanup(srv.Close)
73+
return srv
74+
}
75+
76+
func TestCheck_BetaWalksPastStableToTheFirstBetaAndStops(t *testing.T) {
77+
var calls atomic.Int32
78+
releases := pagedReleasesServer(t, &calls,
79+
[]fakeRelease{{tag: "v0.158.0"}},
80+
[]fakeRelease{{tag: "v0.159.0-beta.1", prerelease: true}},
81+
[]fakeRelease{{tag: "v0.157.0"}},
82+
)
83+
c := newCheckerOnFakes(t, "v0.158.0-beta.3", releases, newMemStore())
84+
if err := c.SetChannel(ChannelBeta); err != nil {
85+
t.Fatalf("SetChannel: %v", err)
86+
}
87+
info, err := c.Check(context.Background(), true)
88+
if err != nil {
89+
t.Fatalf("Check: %v", err)
90+
}
91+
if info.Latest != "v0.159.0-beta.1" || !info.UpdateAvailable {
92+
t.Fatalf("beta info = %+v", info)
93+
}
94+
if got := calls.Load(); got != 2 {
95+
t.Fatalf("pages fetched = %d, want 2: the walk stops at the first beta", got)
96+
}
97+
}

‎go/internal/selfupdate/selfupdate.go‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -396,16 +396,33 @@ func (c *Checker) Check(ctx context.Context, force bool) (Info, error) {
396396
// resolveChannel selects the newest 0.x release on channel. Beta includes
397397
// stable releases, so beta testers converge back to a promoted stable build
398398
// instead of remaining pinned to the final prerelease.
399+
//
400+
// GitHub lists releases newest-created first. The release-order check
401+
// (scripts/check-native-release-order.py) refuses a stable below a published
402+
// stable and a beta below any published release. So the first stable listed
403+
// is the newest stable, and nothing listed after the first beta outranks it.
404+
// A stable may be promoted after newer betas, so on beta the walk keeps the
405+
// higher of the first stable and the first beta.
399406
func (c *Checker) resolveChannel(ctx context.Context, channel Channel) (ghRelease, error) {
400-
return c.fetchReleaseList(ctx, func(rel ghRelease) bool {
407+
var best ghRelease
408+
err := c.walkReleases(ctx, func(rel ghRelease) bool {
401409
if !strings.HasPrefix(rel.TagName, "v0.") {
402410
return false
403411
}
404-
if channel == ChannelStable {
405-
return !rel.Prerelease && isStableTag(rel.TagName)
412+
stable := !rel.Prerelease && isStableTag(rel.TagName)
413+
beta := channel != ChannelStable && rel.Prerelease && isBetaTag(rel.TagName)
414+
if !stable && !beta {
415+
return false
416+
}
417+
if best.TagName == "" || isNewer(rel.TagName, best.TagName) {
418+
best = rel
406419
}
407-
return !rel.Prerelease && isStableTag(rel.TagName) || rel.Prerelease && isBetaTag(rel.TagName)
420+
return beta || channel == ChannelStable
408421
})
422+
if err != nil {
423+
return ghRelease{}, err
424+
}
425+
return best, nil
409426
}
410427

411428
func hasNativeAssets(rel ghRelease, arch string) bool {
@@ -486,40 +503,42 @@ type ghRelease struct {
486503
} `json:"assets"`
487504
}
488505

489-
func (c *Checker) fetchReleaseList(ctx context.Context, accept func(ghRelease) bool) (ghRelease, error) {
506+
// walkReleases visits published releases in list order, page by page, until
507+
// visit returns true or the list ends.
508+
func (c *Checker) walkReleases(ctx context.Context, visit func(ghRelease) bool) error {
490509
rawURL := c.cfg.ReleasesURL
491510
seen := make(map[string]bool)
492511
for rawURL != "" {
493512
if seen[rawURL] {
494-
return ghRelease{}, errors.New("github releases list: repeated page")
513+
return errors.New("github releases list: repeated page")
495514
}
496515
seen[rawURL] = true
497516
resp, err := c.getGitHub(ctx, rawURL)
498517
if err != nil {
499-
return ghRelease{}, err
518+
return err
500519
}
501520
if resp.StatusCode >= 400 {
502521
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
503522
_ = resp.Body.Close()
504-
return ghRelease{}, fmt.Errorf("github releases list %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
523+
return fmt.Errorf("github releases list %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
505524
}
506525
var releases []ghRelease
507526
decodeErr := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&releases)
508527
_ = resp.Body.Close()
509528
if decodeErr != nil {
510-
return ghRelease{}, decodeErr
529+
return decodeErr
511530
}
512531
for _, rel := range releases {
513-
if !rel.Draft && accept(rel) {
514-
return rel, nil
532+
if !rel.Draft && visit(rel) {
533+
return nil
515534
}
516535
}
517536
rawURL, err = nextReleasePage(resp.Header.Get("Link"), rawURL)
518537
if err != nil {
519-
return ghRelease{}, err
538+
return err
520539
}
521540
}
522-
return ghRelease{}, nil
541+
return nil
523542
}
524543

525544
func nextReleasePage(linkHeader, currentURL string) (string, error) {

‎scripts/check-native-release-order.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
#!/usr/bin/env python3
2-
"""Refuse a native release older than a published native candidate."""
2+
"""Refuse a native release older than a published native candidate.
3+
4+
A stable promotes a beta that has been tested for days, so newer betas may
5+
exist by then. A stable is refused only when a newer stable exists.
6+
"""
37

48
import json
59
from pathlib import Path
@@ -32,7 +36,7 @@ def check(tag, pages):
3236
continue
3337
other_tag = release["tag_name"]
3438
other = rank(other_tag)
35-
if other is not None and other > target:
39+
if other is not None and other > target and (other[2] or not target[2]):
3640
raise ValueError(f"{tag} is older than published {other_tag}")
3741

3842

‎scripts/test_check_native_release_order.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,60 @@ def test_published_stable_closes_its_beta_line(self):
3232
with self.assertRaises(ValueError):
3333
release_order.check("v0.131.0-beta.3", pages)
3434

35+
def test_newer_beta_does_not_block_tested_stable(self):
36+
release_order.check("v0.158.0", [[
37+
{"tag_name": "v0.159.0-beta.1", "draft": False},
38+
{"tag_name": "v0.158.0-beta.3", "draft": False},
39+
]])
40+
release_order.check("v0.157.2", [[
41+
{"tag_name": "v0.157.3-beta.1", "draft": False},
42+
{"tag_name": "v0.157.2-beta.1", "draft": False},
43+
]])
44+
release_order.check("v0.158.1", [[
45+
{"tag_name": "v0.159.0-beta.1", "draft": False},
46+
{"tag_name": "v0.158.0", "draft": False},
47+
{"tag_name": "v0.158.1-beta.1", "draft": False},
48+
]])
49+
50+
def test_rejects_stable_older_than_published_stable(self):
51+
pages = [[
52+
{"tag_name": "v0.159.0", "draft": False},
53+
{"tag_name": "v0.158.1-beta.1", "draft": False},
54+
]]
55+
for tag in ("v0.158.1", "v0.158.0-beta.4"):
56+
with self.subTest(tag=tag), self.assertRaises(ValueError):
57+
release_order.check(tag, pages)
58+
59+
def test_rejects_beta_older_than_published_beta(self):
60+
with self.assertRaises(ValueError):
61+
release_order.check("v0.158.1-beta.1", [[
62+
{"tag_name": "v0.159.0-beta.1", "draft": False},
63+
]])
64+
65+
def test_after_late_stable_betas_stay_above_every_release(self):
66+
# The updater stops at the first beta it lists (selfupdate.go
67+
# resolveChannel), so no beta may rank below a published release.
68+
pages = [[
69+
{"tag_name": "v0.158.0", "draft": False},
70+
{"tag_name": "v0.159.0-beta.1", "draft": False},
71+
]]
72+
release_order.check("v0.159.0-beta.2", pages)
73+
release_order.check("v0.159.1-beta.1", pages)
74+
release_order.check("v0.158.0", pages)
75+
with self.assertRaises(ValueError):
76+
release_order.check("v0.158.0-beta.4", pages)
77+
78+
def test_newer_stable_on_a_later_page_blocks_stable(self):
79+
with self.assertRaises(ValueError):
80+
release_order.check("v0.158.0", [
81+
[{"tag_name": "v0.160.0-beta.1", "draft": False}],
82+
[{"tag_name": "v0.159.0", "draft": False}],
83+
])
84+
release_order.check("v0.158.0", [[
85+
{"tag_name": "v0.159.0", "draft": True},
86+
{"tag_name": "v0.159.0-beta.1", "draft": False},
87+
]])
88+
3589
def test_drafts_do_not_block_recovery(self):
3690
release_order.check("v0.131.0-beta.1", [[
3791
{"tag_name": "v0.131.0-beta.2", "draft": True},

0 commit comments

Comments
 (0)