From c8144ddf0f402c02cfaaf02f12223b432fea704f Mon Sep 17 00:00:00 2001 From: Will Hannah Date: Thu, 11 Dec 2025 16:55:30 -0500 Subject: [PATCH] clientupdate: support updating to release candidates Adds a new track for release candidates which is mapped to a new Updater Arguments field: acceptReleaseCandidates. When calling update, if the "release-candidate" track is provided, both the stable and release-candidate tracks are checked for updates. The newer of the two versions is selected. When calling version with the --upstream and --accept-release-candidates flags, the latest release-candidate version is shown if it is newer than the stable version. Alpine updates to release candidates are not yet supported. updates #18193 Signed-off-by: Will Hannah --- clientupdate/clientupdate.go | 161 ++++++++++++++++++++++----- clientupdate/clientupdate_windows.go | 11 +- cmd/tailscale/cli/update.go | 5 +- cmd/tailscale/cli/version.go | 10 +- 4 files changed, 147 insertions(+), 40 deletions(-) diff --git a/clientupdate/clientupdate.go b/clientupdate/clientupdate.go index 3a0a8d03e..1aff5b661 100644 --- a/clientupdate/clientupdate.go +++ b/clientupdate/clientupdate.go @@ -37,8 +37,9 @@ ) const ( - StableTrack = "stable" - UnstableTrack = "unstable" + StableTrack = "stable" + UnstableTrack = "unstable" + ReleaseCandidateTrack = "release-candidate" ) var CurrentTrack = func() string { @@ -79,6 +80,8 @@ type Arguments struct { // running binary // - StableTrack and UnstableTrack will use the latest versions of the // corresponding tracks + // - ReleaseCandidateTrack will use the newest version from StableTrack + // and ReleaseCandidateTrack. // // Leaving this empty will use Version or fall back to CurrentTrack if both // Track and Version are empty. @@ -113,7 +116,7 @@ func (args Arguments) validate() error { return fmt.Errorf("only one of Version(%q) or Track(%q) can be set", args.Version, args.Track) } switch args.Track { - case StableTrack, UnstableTrack, "": + case StableTrack, UnstableTrack, ReleaseCandidateTrack, "": // All valid values. default: return fmt.Errorf("unsupported track %q", args.Track) @@ -131,6 +134,10 @@ type Updater struct { // returned by version.Short(), typically "x.y.z". Used for tests to // override the actual current version. currentVersion string + + // acceptReleaseCandidates is true when the provided track is ReleaseCandidateTrack. + // This allows the installation of the newer of: the latest stable and the latest RC. + acceptReleaseCandidates bool } func NewUpdater(args Arguments) (*Updater, error) { @@ -163,6 +170,10 @@ func NewUpdater(args Arguments) (*Updater, error) { up.Track = CurrentTrack } } + if up.Track == ReleaseCandidateTrack { + up.acceptReleaseCandidates = true + up.Track = StableTrack + } if up.Arguments.PkgsAddr == "" { up.Arguments.PkgsAddr = "https://pkgs.tailscale.com" } @@ -326,6 +337,19 @@ func (up *Updater) updateSynology() error { if err != nil { return err } + + track := up.Track + + // If we're accepting release candidates, check both tracks and choose the newer of the two. + if up.acceptReleaseCandidates { + latestRC, err := latestPackages(ReleaseCandidateTrack) + // If an RC is found and its newer than the last up.Track version, use the RC. + if err == nil && cmpver.Compare(latestRC.SPKsVersion, latest.SPKsVersion) > 0 { + latest = latestRC + track = ReleaseCandidateTrack + } + } + spkName := latest.SPKs[osName][arch] if spkName == "" { return fmt.Errorf("cannot find Synology package for os=%s arch=%s, please report a bug with your device model", osName, arch) @@ -341,7 +365,7 @@ func (up *Updater) updateSynology() error { if err != nil { return err } - pkgsPath := fmt.Sprintf("%s/%s", up.Track, spkName) + pkgsPath := fmt.Sprintf("%s/%s", track, spkName) spkPath := filepath.Join(spkDir, path.Base(pkgsPath)) if err := up.downloadURLToFile(pkgsPath, spkPath); err != nil { return err @@ -440,7 +464,7 @@ func (up *Updater) updateDebLike() error { // instead. return up.updateLinuxBinary() } - ver, err := requestedTailscaleVersion(up.Version, up.Track) + ver, isRC, err := requestedTailscaleVersion(up.Version, up.Track, up.acceptReleaseCandidates) if err != nil { return err } @@ -448,12 +472,27 @@ func (up *Updater) updateDebLike() error { return nil } - if updated, err := updateDebianAptSourcesList(up.Track); err != nil { + track := up.Track + + // If the update was found in the RC track, internally update to use the RC track. + if isRC { + track = ReleaseCandidateTrack + } + + if updated, err := updateDebianAptSourcesList(track); err != nil { return err } else if updated { - up.Logf("Updated %s to use the %s track", aptSourcesFile, up.Track) + up.Logf("Updated %s to use the %s track", aptSourcesFile, track) } + defer func() { + // If the update was found in the RC track, revert the sources list to + // the original up.Track to avoid missing subsequent patch versions. + if isRC { + updateDebianAptSourcesList(up.Track) + } + }() + cmd := exec.Command("apt-get", "update", // Only update the tailscale repo, not the other ones, treating // the tailscale.list file as the main "sources.list" file. @@ -517,17 +556,22 @@ func updateDebianAptSourcesListBytes(was []byte, dstTrack string) (newContent [] bs := bufio.NewScanner(bytes.NewReader(was)) hadCorrect := false commentLine := regexp.MustCompile(`^\s*\#`) - pkgsURL := regexp.MustCompile(`\bhttps://pkgs\.tailscale\.com/((un)?stable)/`) + pkgsURL := regexp.MustCompile(`(^|\s)https://pkgs\.tailscale\.com/(stable|unstable|release-candidate)/`) for bs.Scan() { line := bs.Bytes() if !commentLine.Match(line) { line = pkgsURL.ReplaceAllFunc(line, func(m []byte) []byte { - if bytes.Equal(m, trackURLPrefix) { + submatches := pkgsURL.FindSubmatch(m) + // submatches[0] is the full match, submatches[1] is the leading + // whitespace or start-of-line anchor, and the remainder is the URL. + leading := submatches[1] + urlPart := submatches[0][len(leading):] + if bytes.Equal(urlPart, trackURLPrefix) { hadCorrect = true } else { changes++ } - return trackURLPrefix + return append(append([]byte{}, leading...), trackURLPrefix...) }) } buf.Write(line) @@ -586,7 +630,7 @@ func (up *Updater) updateFedoraLike(packageManager string) func() error { } }() - ver, err := requestedTailscaleVersion(up.Version, up.Track) + ver, isRC, err := requestedTailscaleVersion(up.Version, up.Track, up.acceptReleaseCandidates) if err != nil { return err } @@ -594,15 +638,33 @@ func (up *Updater) updateFedoraLike(packageManager string) func() error { return nil } - if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.Track); err != nil { + track := up.Track + + // If the update was found in the RC track, internally update to use the RC track. + if isRC { + track = ReleaseCandidateTrack + } + + if updated, err := updateYUMRepoTrack(yumRepoConfigFile, track); err != nil { return err } else if updated { - up.Logf("Updated %s to use the %s track", yumRepoConfigFile, up.Track) + up.Logf("Updated %s to use the %s track", yumRepoConfigFile, track) } cmd := exec.Command(packageManager, "install", "--assumeyes", fmt.Sprintf("tailscale-%s-1", ver)) cmd.Stdout = up.Stdout cmd.Stderr = up.Stderr + + // If the update was found in the RC track, revert the package manager's config file to + // the original up.Track to avoid missing subsequent patch versions as they are released. + if isRC { + if updated, err := updateYUMRepoTrack(yumRepoConfigFile, up.Track); err != nil { + up.Logf("failed to revert %s to use the %s track: %v", yumRepoConfigFile, up.Track, err) + } else if updated { + up.Logf("Reverted %s to use the %s track", yumRepoConfigFile, up.Track) + } + } + if err := cmd.Run(); err != nil { return err } @@ -618,8 +680,8 @@ func updateYUMRepoTrack(repoFile, dstTrack string) (rewrote bool, err error) { return false, err } - urlRe := regexp.MustCompile(`^(baseurl|gpgkey)=https://pkgs\.tailscale\.com/(un)?stable/`) - urlReplacement := fmt.Sprintf("$1=https://pkgs.tailscale.com/%s/", dstTrack) + urlRe := regexp.MustCompile(`^(baseurl|gpgkey)=https://pkgs\.tailscale\.com/(stable|unstable|release-candidate)`) + urlReplacement := fmt.Sprintf("$1=https://pkgs.tailscale.com/%s", dstTrack) s := bufio.NewScanner(bytes.NewReader(was)) newContent := bytes.NewBuffer(make([]byte, 0, len(was))) @@ -726,7 +788,7 @@ func parseAlpinePackageVersion(out []byte) (string, error) { var apkRepoVersionRE = regexp.MustCompile(`v[0-9]+\.[0-9]+`) func checkOutdatedAlpineRepo(logf logger.Logf, apkVer, track string) error { - latest, err := LatestTailscaleVersion(track) + latest, _, err := LatestTailscaleVersion(track, false) if err != nil { return err } @@ -846,7 +908,7 @@ func (up *Updater) updateLinuxBinary() error { if err := requireRoot(); err != nil { return err } - ver, err := requestedTailscaleVersion(up.Version, up.Track) + ver, isRC, err := requestedTailscaleVersion(up.Version, up.Track, up.acceptReleaseCandidates) if err != nil { return err } @@ -854,6 +916,17 @@ func (up *Updater) updateLinuxBinary() error { return nil } + originalTrack := up.Track + + defer func() { + up.Track = originalTrack + }() + + // If an RC was found, internally update the working track to the RC track. + if isRC { + up.Track = ReleaseCandidateTrack + } + dlPath, err := up.downloadLinuxTarball(ver) if err != nil { return err @@ -1148,24 +1221,56 @@ func haveExecutable(name string) bool { return err == nil && path != "" } -func requestedTailscaleVersion(ver, track string) (string, error) { +func requestedTailscaleVersion(ver, track string, acceptReleaseCandidates bool) (string, bool, error) { if ver != "" { - return ver, nil + return ver, false, nil } - return LatestTailscaleVersion(track) + return LatestTailscaleVersion(track, acceptReleaseCandidates) } // LatestTailscaleVersion returns the latest released version for the given -// track from pkgs.tailscale.com. -func LatestTailscaleVersion(track string) (string, error) { +// track from pkgs.tailscale.com. If track is empty, CurrentTrack is used. Returns +// the version found, whether or not it is an RC version, and any error. +func LatestTailscaleVersion(track string, acceptReleaseCandidates bool) (string, bool, error) { if track == "" { track = CurrentTrack } - latest, err := latestPackages(track) - if err != nil { - return "", err + testTrack := track + + // For ReleaseCandidateTrack, take the newer of StableTrack and ReleaseCandidateTrack. + // This avoids trapping users on an older RC after a patch stable release is made. + if track == ReleaseCandidateTrack { + testTrack = StableTrack } + latest, err := latestPackages(testTrack) + if err != nil { + return "", false, err + } + + // First, find the latest version on the requested track. + ver := latestPlatformVersion(latest) + + if !acceptReleaseCandidates && ver == "" { + return "", false, fmt.Errorf("no latest version found for OS %q on %q track", runtime.GOOS, track) + } else if !acceptReleaseCandidates && ver != "" { + return ver, false, nil + } + + // Consider the latest RC version if it's newer than the stable version just found. + if latestRC, err := latestPackages(ReleaseCandidateTrack); err == nil && cmpver.Compare(latestRC.Version, ver) > 0 { + ver = latestPlatformVersion(latestRC) + return ver, true, nil + } + + if ver == "" { + return "", false, fmt.Errorf("no latest version or RC found for OS %q on %q track", runtime.GOOS, track) + } + + return ver, false, nil +} + +func latestPlatformVersion(latest *trackPackages) string { ver := latest.Version switch runtime.GOOS { case "windows": @@ -1178,11 +1283,7 @@ func LatestTailscaleVersion(track string) (string, error) { ver = latest.SPKsVersion } } - - if ver == "" { - return "", fmt.Errorf("no latest version found for OS %q on %q track", runtime.GOOS, track) - } - return ver, nil + return ver } type trackPackages struct { diff --git a/clientupdate/clientupdate_windows.go b/clientupdate/clientupdate_windows.go index 5faeda6dd..db69dbf5f 100644 --- a/clientupdate/clientupdate_windows.go +++ b/clientupdate/clientupdate_windows.go @@ -114,7 +114,7 @@ func (up *Updater) updateWindows() error { * press Windows+x, then press a * press Windows+r, type in "cmd", then press Ctrl+Shift+Enter`) } - ver, err := requestedTailscaleVersion(up.Version, up.Track) + ver, isRC, err := requestedTailscaleVersion(up.Version, up.Track, up.acceptReleaseCandidates) if err != nil { return err } @@ -126,6 +126,13 @@ func (up *Updater) updateWindows() error { return nil } + track := up.Track + + // If the update was found in the RC track, internally update to use the RC track. + if isRC { + track = ReleaseCandidateTrack + } + tsDir := filepath.Join(os.Getenv("ProgramData"), "Tailscale") msiDir := filepath.Join(tsDir, "MSICache") if fi, err := os.Stat(tsDir); err != nil { @@ -145,7 +152,7 @@ func (up *Updater) updateWindows() error { qualifiers = append(qualifiers, "winui") } - pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s.msi", up.Track, strings.Join(qualifiers, "-")) + pkgsPath := fmt.Sprintf("%s/tailscale-setup-%s.msi", track, strings.Join(qualifiers, "-")) msiTarget := filepath.Join(msiDir, path.Base(pkgsPath)) if err := up.downloadURLToFile(pkgsPath, msiTarget); err != nil { return err diff --git a/cmd/tailscale/cli/update.go b/cmd/tailscale/cli/update.go index 7eb0dccac..768530444 100644 --- a/cmd/tailscale/cli/update.go +++ b/cmd/tailscale/cli/update.go @@ -40,7 +40,7 @@ distro.Get() != distro.Synology && runtime.GOOS != "freebsd" && runtime.GOOS != "darwin" { - fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable" or "unstable" (dev); empty means same as current`) + fs.StringVar(&updateArgs.track, "track", "", `which track to check for updates: "stable", "release-candidate", or "unstable" (dev); empty means same as current`) fs.StringVar(&updateArgs.version, "version", "", `explicit version to update/downgrade to`) } return fs @@ -58,9 +58,6 @@ func runUpdate(ctx context.Context, args []string) error { if len(args) > 0 { return flag.ErrHelp } - if updateArgs.version != "" && updateArgs.track != "" { - return errors.New("cannot specify both --version and --track") - } err := clientupdate.Update(clientupdate.Arguments{ Version: updateArgs.version, Track: updateArgs.track, diff --git a/cmd/tailscale/cli/version.go b/cmd/tailscale/cli/version.go index b25502d5a..f774bbb61 100644 --- a/cmd/tailscale/cli/version.go +++ b/cmd/tailscale/cli/version.go @@ -24,15 +24,17 @@ fs.BoolVar(&versionArgs.daemon, "daemon", false, "also print local node's daemon version") fs.BoolVar(&versionArgs.json, "json", false, "output in JSON format") fs.BoolVar(&versionArgs.upstream, "upstream", false, "fetch and print the latest upstream release version from pkgs.tailscale.com") + fs.BoolVar(&versionArgs.acceptReleaseCandidates, "accept-release-candidates", false, "when used with -upstream, release candidates will be considered as valid latest versions") return fs })(), Exec: runVersion, } var versionArgs struct { - daemon bool // also check local node's daemon version - json bool - upstream bool + daemon bool // also check local node's daemon version + json bool + upstream bool + acceptReleaseCandidates bool } func runVersion(ctx context.Context, args []string) error { @@ -51,7 +53,7 @@ func runVersion(ctx context.Context, args []string) error { var upstreamVer string if versionArgs.upstream { - upstreamVer, err = clientupdate.LatestTailscaleVersion(clientupdate.CurrentTrack) + upstreamVer, _, err = clientupdate.LatestTailscaleVersion(clientupdate.CurrentTrack, versionArgs.acceptReleaseCandidates) if err != nil { return err }