feat: signed releases, release feed, notification (read-only)

This commit is contained in:
fsylvain committed 2026-10-01 15:01:53 +02:00
1 parent a49eec909c
commit 834493a4f5
49 files changed
+4191 -18

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
package update
import (
"errors"
"time"
)
const StateIdle = "idle"
var (
ErrNoRelease = errors.New("no release found")
ErrInvalidVersion = errors.New("invalid version")
ErrFeedUnavailable = errors.New("release feed unavailable")
)
type ReleaseNotes struct {
Version string `json:"version"`
ReleasedAt time.Time `json:"released_at"`
Items []string `json:"items"`
}
type Release struct {
Tag string
URL string
PublishedAt time.Time
Notes ReleaseNotes
}
type CheckResult struct {
Latest string `json:"latest"`
ReleaseURL string `json:"release_url"`
Notes ReleaseNotes `json:"notes"`
CheckedAt time.Time `json:"checked_at"`
}
type Progress struct {
Percent int `json:"percent"`
DownloadedBytes int64 `json:"downloaded_bytes"`
TotalBytes int64 `json:"total_bytes"`
}
type UpdateStatus struct {
Current string `json:"current"`
Latest string `json:"latest"`
Available bool `json:"available"`
ReleaseURL *string `json:"release_url"`
Notes *ReleaseNotes `json:"notes"`
CheckedAt *time.Time `json:"checked_at"`
CanApply bool `json:"can_apply"`
State string `json:"state"`
Progress *Progress `json:"progress"`
Error *string `json:"error"`
StartedAt *time.Time `json:"started_at"`
}
func NewResult(release Release, checkedAt time.Time) CheckResult {
return CheckResult{
Latest: release.Tag,
ReleaseURL: release.URL,
Notes: release.Notes,
CheckedAt: checkedAt.UTC(),
}
}
func NewStatus(current string, result *CheckResult) UpdateStatus {
status := UpdateStatus{Current: current, State: StateIdle}
if result == nil {
return status
}
releaseURL := result.ReleaseURL
notes := result.Notes
notes.Items = nonNilItems(notes.Items)
checkedAt := result.CheckedAt
status.Latest = result.Latest
status.Available = IsNewer(current, result.Latest)
status.ReleaseURL = &releaseURL
status.Notes = &notes
status.CheckedAt = &checkedAt
return status
}
func nonNilItems(items []string) []string {
if items == nil {
return []string{}
}
return items
}
func Newest(releases []Release) (Release, error) {
var newest Release
found := false
for _, release := range releases {
if _, err := ParseVersion(release.Tag); err != nil {
continue
}
if !found || IsNewer(newest.Tag, release.Tag) {
newest = release
found = true
}
}
if !found {
return Release{}, ErrNoRelease
}
return newest, nil
}
+37
View File
@@ -0,0 +1,37 @@
package update
import (
"regexp"
"strings"
"time"
)
var (
bulletLine = regexp.MustCompile(`^[-*+]\s+(.+)$`)
markdownLink = regexp.MustCompile(`!?\[([^\]]*)\]\([^)]*\)`)
boldText = regexp.MustCompile(`\*\*([^*]+)\*\*|__([^_]+)__`)
italicText = regexp.MustCompile(`\*([^*\s][^*]*)\*`)
inlineCode = regexp.MustCompile("`([^`]*)`")
)
func BuildNotes(tag string, publishedAt time.Time, body string) ReleaseNotes {
items := []string{}
for _, line := range strings.Split(body, "\n") {
match := bulletLine.FindStringSubmatch(strings.TrimSpace(line))
if match == nil {
continue
}
if item := plainText(match[1]); item != "" {
items = append(items, item)
}
}
return ReleaseNotes{Version: tag, ReleasedAt: publishedAt, Items: items}
}
func plainText(markdown string) string {
text := markdownLink.ReplaceAllString(markdown, "$1")
text = boldText.ReplaceAllString(text, "$1$2")
text = italicText.ReplaceAllString(text, "$1")
text = inlineCode.ReplaceAllString(text, "$1")
return strings.TrimSpace(text)
}
@@ -0,0 +1,10 @@
package update
type ReleaseFeed interface {
Latest() (Release, error)
}
type StatusStore interface {
Load() (CheckResult, bool)
Save(result CheckResult) error
}
+101
View File
@@ -0,0 +1,101 @@
package update
import (
"cmp"
"strconv"
"strings"
)
type Version struct {
numbers []int
pre []string
}
func ParseVersion(raw string) (Version, error) {
trimmed := strings.TrimPrefix(strings.TrimSpace(raw), "v")
core, pre, hasPre := strings.Cut(trimmed, "-")
if core == "" || (hasPre && pre == "") {
return Version{}, ErrInvalidVersion
}
var version Version
for _, segment := range strings.Split(core, ".") {
number, err := strconv.Atoi(segment)
if err != nil || number < 0 {
return Version{}, ErrInvalidVersion
}
version.numbers = append(version.numbers, number)
}
if hasPre {
version.pre = strings.Split(pre, ".")
}
return version, nil
}
func IsNewer(current, latest string) bool {
currentVersion, err := ParseVersion(current)
if err != nil {
return false
}
latestVersion, err := ParseVersion(latest)
if err != nil {
return false
}
return compareVersions(latestVersion, currentVersion) > 0
}
func compareVersions(a, b Version) int {
if order := compareNumbers(a.numbers, b.numbers); order != 0 {
return order
}
return comparePreRelease(a.pre, b.pre)
}
func compareNumbers(a, b []int) int {
for i := range max(len(a), len(b)) {
if order := cmp.Compare(segmentAt(a, i), segmentAt(b, i)); order != 0 {
return order
}
}
return 0
}
func segmentAt(numbers []int, index int) int {
if index < len(numbers) {
return numbers[index]
}
return 0
}
func comparePreRelease(a, b []string) int {
if len(a) == 0 && len(b) == 0 {
return 0
}
if len(a) == 0 {
return 1
}
if len(b) == 0 {
return -1
}
length := min(len(a), len(b))
for i := 0; i < length; i++ {
if order := compareIdentifiers(a[i], b[i]); order != 0 {
return order
}
}
return cmp.Compare(len(a), len(b))
}
func compareIdentifiers(a, b string) int {
numberA, errA := strconv.Atoi(a)
numberB, errB := strconv.Atoi(b)
if errA == nil && errB == nil {
return cmp.Compare(numberA, numberB)
}
if errA == nil {
return -1
}
if errB == nil {
return 1
}
return strings.Compare(a, b)
}