feat: signed releases, release feed, notification (read-only)
This commit is contained in:
1 parent
a49eec909c
commit
834493a4f5
49 files changed
+4191
-18
No files matched your search
@@ -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 = ¬es
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user