Files
tbphp 5d9b0015e2 feat: 增加版本更新提示与社区入口 (#438)
* feat: 增加版本更新提示与社区入口

* fix(release): 完善版本检查分页与测试隔离

* fix(release): 改为按需触发更新检查
2026-08-20 10:17:30 +08:00

215 lines
5.2 KiB
Go

package releasecheck
import (
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
const supportedMajor uint64 = 2
const maxSafeInteger int64 = 1<<53 - 1
var releaseVersionPattern = regexp.MustCompile(
`^(?:v)?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$`,
)
// Release is the public GitHub metadata used to select a newer version.
type Release struct {
TagName string
HTMLURL string
PublishedAt time.Time
Draft bool
}
// Update is the immutable public result exposed to the control plane.
type Update struct {
Version string
ReleaseURL string
PublishedAtMS int64
}
// SelectUpdate returns the highest eligible V2 release newer than current.
func SelectUpdate(current string, releases []Release) *Update {
currentVersion, ok := parseVersion(current)
if !ok || currentVersion.major != supportedMajor {
return nil
}
var selected *Release
var selectedVersion semanticVersion
for index := range releases {
candidate := &releases[index]
candidateVersion, valid := validCandidate(*candidate)
if !valid || candidateVersion.major != supportedMajor ||
compareVersions(candidateVersion, currentVersion) <= 0 ||
(currentVersion.stable() && !candidateVersion.stable()) {
continue
}
if selected == nil || compareVersions(candidateVersion, selectedVersion) > 0 {
selected = candidate
selectedVersion = candidateVersion
}
}
if selected == nil {
return nil
}
return &Update{
Version: selected.TagName,
ReleaseURL: selected.HTMLURL,
PublishedAtMS: selected.PublishedAt.UnixMilli(),
}
}
type semanticVersion struct {
major uint64
minor uint64
patch uint64
prerelease []prereleaseIdentifier
}
type prereleaseIdentifier struct {
raw string
number uint64
numeric bool
}
func (version semanticVersion) stable() bool {
return len(version.prerelease) == 0
}
func parseVersion(raw string) (semanticVersion, bool) {
matches := releaseVersionPattern.FindStringSubmatch(strings.TrimSpace(raw))
if matches == nil {
return semanticVersion{}, false
}
major, ok := parseVersionNumber(matches[1])
if !ok {
return semanticVersion{}, false
}
minor, ok := parseVersionNumber(matches[2])
if !ok {
return semanticVersion{}, false
}
patch, ok := parseVersionNumber(matches[3])
if !ok {
return semanticVersion{}, false
}
result := semanticVersion{major: major, minor: minor, patch: patch}
if matches[4] == "" {
return result, true
}
for _, rawIdentifier := range strings.Split(matches[4], ".") {
identifier := prereleaseIdentifier{raw: rawIdentifier}
if allASCIIBytes(rawIdentifier, '0', '9') {
if len(rawIdentifier) > 1 && rawIdentifier[0] == '0' {
return semanticVersion{}, false
}
identifier.number, ok = parseVersionNumber(rawIdentifier)
if !ok {
return semanticVersion{}, false
}
identifier.numeric = true
}
result.prerelease = append(result.prerelease, identifier)
}
return result, true
}
func parseVersionNumber(raw string) (uint64, bool) {
value, err := strconv.ParseUint(raw, 10, 64)
return value, err == nil
}
func allASCIIBytes(value string, minimum, maximum byte) bool {
if value == "" {
return false
}
for index := range len(value) {
if value[index] < minimum || value[index] > maximum {
return false
}
}
return true
}
func compareVersions(left, right semanticVersion) int {
for _, pair := range [][2]uint64{
{left.major, right.major},
{left.minor, right.minor},
{left.patch, right.patch},
} {
if pair[0] < pair[1] {
return -1
}
if pair[0] > pair[1] {
return 1
}
}
if left.stable() {
if right.stable() {
return 0
}
return 1
}
if right.stable() {
return -1
}
for index := 0; index < len(left.prerelease) && index < len(right.prerelease); index++ {
leftIdentifier := left.prerelease[index]
rightIdentifier := right.prerelease[index]
switch {
case leftIdentifier.numeric && rightIdentifier.numeric:
if leftIdentifier.number < rightIdentifier.number {
return -1
}
if leftIdentifier.number > rightIdentifier.number {
return 1
}
case leftIdentifier.numeric:
return -1
case rightIdentifier.numeric:
return 1
case leftIdentifier.raw < rightIdentifier.raw:
return -1
case leftIdentifier.raw > rightIdentifier.raw:
return 1
}
}
if len(left.prerelease) < len(right.prerelease) {
return -1
}
if len(left.prerelease) > len(right.prerelease) {
return 1
}
return 0
}
func validCandidate(release Release) (semanticVersion, bool) {
version, ok := parseVersion(release.TagName)
if !ok || release.Draft || !validPublishedAt(release.PublishedAt) ||
!validReleaseURL(release.HTMLURL, release.TagName) {
return semanticVersion{}, false
}
return version, true
}
func validPublishedAt(publishedAt time.Time) bool {
if publishedAt.IsZero() {
return false
}
milliseconds := publishedAt.UnixMilli()
return milliseconds >= 0 && milliseconds <= maxSafeInteger
}
func validReleaseURL(raw, tag string) bool {
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "https" || parsed.Host != "github.com" ||
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return false
}
return parsed.Path == "/tbphp/gpt-load/releases/tag/"+tag
}