@@ -3,7 +3,9 @@ package main
33//go:generate go run main.go
44
55import (
6+ "context"
67 "encoding/json"
8+ "errors"
79 "fmt"
810 "go/format"
911 "io"
@@ -21,64 +23,211 @@ import (
2123)
2224
2325const (
24- releasesURL = "https://api.github.com/repos/tailscale/tailscale/releases"
25- rawFileURL = "https://github.com/tailscale/tailscale/raw/refs/tags/%s/tailcfg/tailcfg.go"
26- outputFile = "../../hscontrol/capver/capver_generated.go"
27- testFile = "../../hscontrol/capver/capver_test_data.go"
28- minVersionParts = 2
29- fallbackCapVer = 90
30- maxTestCases = 4
31- // TODO(https://github.com/tailscale/tailscale/issues/12849): Restore to 10 when v1.92 is released.
32- supportedMajorMinorVersions = 9
26+ ghcrTokenURL = "https://ghcr.io/token?service=ghcr.io&scope=repository:tailscale/tailscale:pull" //nolint:gosec
27+ ghcrTagsURL = "https://ghcr.io/v2/tailscale/tailscale/tags/list?n=10000"
28+ rawFileURL = "https://github.com/tailscale/tailscale/raw/refs/tags/%s/tailcfg/tailcfg.go"
29+ outputFile = "../../hscontrol/capver/capver_generated.go"
30+ testFile = "../../hscontrol/capver/capver_test_data.go"
31+ fallbackCapVer = 90
32+ maxTestCases = 4
33+ supportedMajorMinorVersions = 10
3334 filePermissions = 0o600
35+ semverMatchGroups = 4
36+ latest3Count = 3
37+ latest2Count = 2
3438)
3539
36- type Release struct {
37- Name string `json:"name"`
40+ var errUnexpectedStatusCode = errors .New ("unexpected status code" )
41+
42+ // GHCRTokenResponse represents the response from GHCR token endpoint.
43+ type GHCRTokenResponse struct {
44+ Token string `json:"token"`
45+ }
46+
47+ // GHCRTagsResponse represents the response from GHCR tags list endpoint.
48+ type GHCRTagsResponse struct {
49+ Name string `json:"name"`
50+ Tags []string `json:"tags"`
3851}
3952
40- func getCapabilityVersions () (map [string ]tailcfg.CapabilityVersion , error ) {
41- // Fetch the releases
42- resp , err := http .Get (releasesURL )
53+ // getGHCRToken fetches an anonymous token from GHCR for accessing public container images.
54+ func getGHCRToken (ctx context.Context ) (string , error ) {
55+ client := & http.Client {}
56+
57+ req , err := http .NewRequestWithContext (ctx , http .MethodGet , ghcrTokenURL , nil )
58+ if err != nil {
59+ return "" , fmt .Errorf ("error creating token request: %w" , err )
60+ }
61+
62+ resp , err := client .Do (req )
4363 if err != nil {
44- return nil , fmt .Errorf ("error fetching releases : %w" , err )
64+ return "" , fmt .Errorf ("error fetching GHCR token : %w" , err )
4565 }
4666 defer resp .Body .Close ()
4767
68+ if resp .StatusCode != http .StatusOK {
69+ return "" , fmt .Errorf ("%w: %d" , errUnexpectedStatusCode , resp .StatusCode )
70+ }
71+
4872 body , err := io .ReadAll (resp .Body )
4973 if err != nil {
50- return nil , fmt .Errorf ("error reading response body: %w" , err )
74+ return "" , fmt .Errorf ("error reading token response: %w" , err )
75+ }
76+
77+ var tokenResp GHCRTokenResponse
78+
79+ err = json .Unmarshal (body , & tokenResp )
80+ if err != nil {
81+ return "" , fmt .Errorf ("error parsing token response: %w" , err )
82+ }
83+
84+ return tokenResp .Token , nil
85+ }
86+
87+ // getGHCRTags fetches all available tags from GHCR for tailscale/tailscale.
88+ func getGHCRTags (ctx context.Context ) ([]string , error ) {
89+ token , err := getGHCRToken (ctx )
90+ if err != nil {
91+ return nil , fmt .Errorf ("failed to get GHCR token: %w" , err )
5192 }
5293
53- var releases [] Release
94+ client := & http. Client {}
5495
55- err = json . Unmarshal ( body , & releases )
96+ req , err := http . NewRequestWithContext ( ctx , http . MethodGet , ghcrTagsURL , nil )
5697 if err != nil {
57- return nil , fmt .Errorf ("error unmarshalling JSON : %w" , err )
98+ return nil , fmt .Errorf ("error creating tags request : %w" , err )
5899 }
59100
101+ req .Header .Set ("Authorization" , "Bearer " + token )
102+
103+ resp , err := client .Do (req )
104+ if err != nil {
105+ return nil , fmt .Errorf ("error fetching tags: %w" , err )
106+ }
107+ defer resp .Body .Close ()
108+
109+ if resp .StatusCode != http .StatusOK {
110+ return nil , fmt .Errorf ("%w: %d" , errUnexpectedStatusCode , resp .StatusCode )
111+ }
112+
113+ body , err := io .ReadAll (resp .Body )
114+ if err != nil {
115+ return nil , fmt .Errorf ("error reading tags response: %w" , err )
116+ }
117+
118+ var tagsResp GHCRTagsResponse
119+
120+ err = json .Unmarshal (body , & tagsResp )
121+ if err != nil {
122+ return nil , fmt .Errorf ("error parsing tags response: %w" , err )
123+ }
124+
125+ return tagsResp .Tags , nil
126+ }
127+
128+ // semverRegex matches semantic version tags like v1.90.0 or v1.90.1.
129+ var semverRegex = regexp .MustCompile (`^v(\d+)\.(\d+)\.(\d+)$` )
130+
131+ // parseSemver extracts major, minor, patch from a semver tag.
132+ // Returns -1 for all values if not a valid semver.
133+ func parseSemver (tag string ) (int , int , int ) {
134+ matches := semverRegex .FindStringSubmatch (tag )
135+ if len (matches ) != semverMatchGroups {
136+ return - 1 , - 1 , - 1
137+ }
138+
139+ major , _ := strconv .Atoi (matches [1 ])
140+ minor , _ := strconv .Atoi (matches [2 ])
141+ patch , _ := strconv .Atoi (matches [3 ])
142+
143+ return major , minor , patch
144+ }
145+
146+ // getMinorVersionsFromTags processes container tags and returns a map of minor versions
147+ // to the first available patch version for each minor.
148+ // For example: {"v1.90": "v1.90.0", "v1.92": "v1.92.0"}.
149+ func getMinorVersionsFromTags (tags []string ) map [string ]string {
150+ // Map minor version (e.g., "v1.90") to lowest patch version available
151+ minorToLowestPatch := make (map [string ]struct {
152+ patch int
153+ fullVer string
154+ })
155+
156+ for _ , tag := range tags {
157+ major , minor , patch := parseSemver (tag )
158+ if major < 0 {
159+ continue // Not a semver tag
160+ }
161+
162+ minorKey := fmt .Sprintf ("v%d.%d" , major , minor )
163+
164+ existing , exists := minorToLowestPatch [minorKey ]
165+ if ! exists || patch < existing .patch {
166+ minorToLowestPatch [minorKey ] = struct {
167+ patch int
168+ fullVer string
169+ }{
170+ patch : patch ,
171+ fullVer : tag ,
172+ }
173+ }
174+ }
175+
176+ // Convert to simple map
177+ result := make (map [string ]string )
178+ for minorVer , info := range minorToLowestPatch {
179+ result [minorVer ] = info .fullVer
180+ }
181+
182+ return result
183+ }
184+
185+ // getCapabilityVersions fetches container tags from GHCR, identifies minor versions,
186+ // and fetches the capability version for each from the Tailscale source.
187+ func getCapabilityVersions (ctx context.Context ) (map [string ]tailcfg.CapabilityVersion , error ) {
188+ // Fetch container tags from GHCR
189+ tags , err := getGHCRTags (ctx )
190+ if err != nil {
191+ return nil , fmt .Errorf ("failed to get container tags: %w" , err )
192+ }
193+
194+ log .Printf ("Found %d container tags" , len (tags ))
195+
196+ // Get minor versions with their representative patch versions
197+ minorVersions := getMinorVersionsFromTags (tags )
198+ log .Printf ("Found %d minor versions" , len (minorVersions ))
199+
60200 // Regular expression to find the CurrentCapabilityVersion line
61201 re := regexp .MustCompile (`const CurrentCapabilityVersion CapabilityVersion = (\d+)` )
62202
63203 versions := make (map [string ]tailcfg.CapabilityVersion )
204+ client := & http.Client {}
64205
65- for _ , release := range releases {
66- version := strings .TrimSpace (release .Name )
67- if ! strings .HasPrefix (version , "v" ) {
68- version = "v" + version
69- }
206+ for minorVer , patchVer := range minorVersions {
207+ // Fetch the raw Go file for the patch version
208+ rawURL := fmt .Sprintf (rawFileURL , patchVer )
70209
71- // Fetch the raw Go file
72- rawURL := fmt .Sprintf (rawFileURL , version )
210+ req , err := http .NewRequestWithContext (ctx , http .MethodGet , rawURL , nil ) //nolint:gosec
211+ if err != nil {
212+ log .Printf ("Warning: failed to create request for %s: %v" , patchVer , err )
213+ continue
214+ }
73215
74- resp , err := http . Get ( rawURL )
216+ resp , err := client . Do ( req )
75217 if err != nil {
218+ log .Printf ("Warning: failed to fetch %s: %v" , patchVer , err )
76219 continue
77220 }
78221 defer resp .Body .Close ()
79222
223+ if resp .StatusCode != http .StatusOK {
224+ log .Printf ("Warning: got status %d for %s" , resp .StatusCode , patchVer )
225+ continue
226+ }
227+
80228 body , err := io .ReadAll (resp .Body )
81229 if err != nil {
230+ log .Printf ("Warning: failed to read response for %s: %v" , patchVer , err )
82231 continue
83232 }
84233
@@ -87,46 +236,29 @@ func getCapabilityVersions() (map[string]tailcfg.CapabilityVersion, error) {
87236 if len (matches ) > 1 {
88237 capabilityVersionStr := matches [1 ]
89238 capabilityVersion , _ := strconv .Atoi (capabilityVersionStr )
90- versions [version ] = tailcfg .CapabilityVersion (capabilityVersion )
239+ versions [minorVer ] = tailcfg .CapabilityVersion (capabilityVersion )
240+ log .Printf (" %s (from %s): capVer %d" , minorVer , patchVer , capabilityVersion )
91241 }
92242 }
93243
94244 return versions , nil
95245}
96246
97247func calculateMinSupportedCapabilityVersion (versions map [string ]tailcfg.CapabilityVersion ) tailcfg.CapabilityVersion {
98- // Get unique major.minor versions
99- majorMinorToCapVer := make (map [string ]tailcfg.CapabilityVersion )
100-
101- for version , capVer := range versions {
102- // Remove 'v' prefix and split by '.'
103- cleanVersion := strings .TrimPrefix (version , "v" )
104-
105- parts := strings .Split (cleanVersion , "." )
106- if len (parts ) >= minVersionParts {
107- majorMinor := parts [0 ] + "." + parts [1 ]
108- // Keep the earliest (lowest) capver for each major.minor
109- if existing , exists := majorMinorToCapVer [majorMinor ]; ! exists || capVer < existing {
110- majorMinorToCapVer [majorMinor ] = capVer
111- }
112- }
113- }
114-
115- // Sort major.minor versions
116- majorMinors := xmaps .Keys (majorMinorToCapVer )
117- sort .Strings (majorMinors )
248+ // Since we now store minor versions directly, just sort and take the oldest of the latest N
249+ minorVersions := xmaps .Keys (versions )
250+ sort .Strings (minorVersions )
118251
119- // Take the latest 10 versions
120- supportedCount := min (len (majorMinors ), supportedMajorMinorVersions )
252+ supportedCount := min (len (minorVersions ), supportedMajorMinorVersions )
121253
122254 if supportedCount == 0 {
123255 return fallbackCapVer
124256 }
125257
126258 // The minimum supported version is the oldest of the latest 10
127- oldestSupportedMajorMinor := majorMinors [len (majorMinors )- supportedCount ]
259+ oldestSupportedMinor := minorVersions [len (minorVersions )- supportedCount ]
128260
129- return majorMinorToCapVer [ oldestSupportedMajorMinor ]
261+ return versions [ oldestSupportedMinor ]
130262}
131263
132264func writeCapabilityVersionsToFile (versions map [string ]tailcfg.CapabilityVersion , minSupportedCapVer tailcfg.CapabilityVersion ) error {
@@ -156,8 +288,8 @@ func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion
156288 capabilityVersion := versions [v ]
157289
158290 // If it is already set, skip and continue,
159- // we only want the first tailscale vsion per
160- // capability vsion .
291+ // we only want the first tailscale version per
292+ // capability version .
161293 if _ , ok := capVarToTailscaleVer [capabilityVersion ]; ok {
162294 continue
163295 }
@@ -199,31 +331,16 @@ func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion
199331}
200332
201333func writeTestDataFile (versions map [string ]tailcfg.CapabilityVersion , minSupportedCapVer tailcfg.CapabilityVersion ) error {
202- // Get unique major.minor versions for test generation
203- majorMinorToCapVer := make (map [string ]tailcfg.CapabilityVersion )
334+ // Sort minor versions
335+ minorVersions := xmaps .Keys (versions )
336+ sort .Strings (minorVersions )
204337
205- for version , capVer := range versions {
206- cleanVersion := strings . TrimPrefix ( version , "v" )
338+ // Take latest N
339+ supportedCount := min ( len ( minorVersions ), supportedMajorMinorVersions )
207340
208- parts := strings .Split (cleanVersion , "." )
209- if len (parts ) >= minVersionParts {
210- majorMinor := parts [0 ] + "." + parts [1 ]
211- if existing , exists := majorMinorToCapVer [majorMinor ]; ! exists || capVer < existing {
212- majorMinorToCapVer [majorMinor ] = capVer
213- }
214- }
215- }
216-
217- // Sort major.minor versions
218- majorMinors := xmaps .Keys (majorMinorToCapVer )
219- sort .Strings (majorMinors )
220-
221- // Take latest 10
222- supportedCount := min (len (majorMinors ), supportedMajorMinorVersions )
223-
224- latest10 := majorMinors [len (majorMinors )- supportedCount :]
225- latest3 := majorMinors [len (majorMinors )- 3 :]
226- latest2 := majorMinors [len (majorMinors )- 2 :]
341+ latest10 := minorVersions [len (minorVersions )- supportedCount :]
342+ latest3 := minorVersions [len (minorVersions )- min (latest3Count , len (minorVersions )):]
343+ latest2 := minorVersions [len (minorVersions )- min (latest2Count , len (minorVersions )):]
227344
228345 // Generate test data file content
229346 var content strings.Builder
@@ -242,7 +359,7 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport
242359 content .WriteString ("\t {3, false, []string{" )
243360
244361 for i , version := range latest3 {
245- content .WriteString (fmt .Sprintf ("\" v %s\" " , version ))
362+ content .WriteString (fmt .Sprintf ("\" %s\" " , version ))
246363
247364 if i < len (latest3 )- 1 {
248365 content .WriteString (", " )
@@ -255,7 +372,9 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport
255372 content .WriteString ("\t {2, true, []string{" )
256373
257374 for i , version := range latest2 {
258- content .WriteString (fmt .Sprintf ("\" %s\" " , version ))
375+ // Strip v prefix for this test case
376+ verNoV := strings .TrimPrefix (version , "v" )
377+ content .WriteString (fmt .Sprintf ("\" %s\" " , verNoV ))
259378
260379 if i < len (latest2 )- 1 {
261380 content .WriteString (", " )
@@ -268,7 +387,8 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport
268387 content .WriteString (fmt .Sprintf ("\t {%d, true, []string{\n " , supportedMajorMinorVersions ))
269388
270389 for _ , version := range latest10 {
271- content .WriteString (fmt .Sprintf ("\t \t \" %s\" ,\n " , version ))
390+ verNoV := strings .TrimPrefix (version , "v" )
391+ content .WriteString (fmt .Sprintf ("\t \t \" %s\" ,\n " , verNoV ))
272392 }
273393
274394 content .WriteString ("\t }},\n " )
@@ -338,7 +458,9 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport
338458}
339459
340460func main () {
341- versions , err := getCapabilityVersions ()
461+ ctx := context .Background ()
462+
463+ versions , err := getCapabilityVersions (ctx )
342464 if err != nil {
343465 log .Println ("Error:" , err )
344466 return
0 commit comments