fix(update): убрать псевдо-версию latest, всё в commit-<sha7>
All checks were successful
CI / test (push) Successful in 59s
CI / build-and-package (amd64, darwin) (push) Successful in 42s
CI / build-and-package (amd64, linux) (push) Successful in 40s
CI / build-and-package (amd64, windows) (push) Successful in 42s
CI / build-and-package (arm64, darwin) (push) Successful in 45s
CI / build-and-package (arm64, linux) (push) Successful in 41s

Gitea: при параллельных PUT матрицы (6 платформ) псевдо-версия 'latest'
переуказывается на каждый снимок, companion-файлы и бинарь разъезжаются
между версиями — checksum U4 ломался.

- CI: публикует бинарь+.version+.sha256 в  (commit-<sha7>), latest не используется
- Updater.ResolveLatest: новейшая commit-* версия из листинга /api/v1/packages/{owner}/generic/{package},
  берём макс id у которой есть бинарь текущей платформы
- Download/Verify/checksum читают ТУ ЖЕ version, а не latest
This commit is contained in:
Hermes
2026-08-16 13:05:37 +05:00
parent 05c17ed1f7
commit 63668a0f69
4 changed files with 127 additions and 71 deletions

View File

@@ -1,11 +1,17 @@
// Package update — система автообновления бинаря ratatoskr.
//
// Источник: Gitea Packages (generic), хранилище `ratatoskr`,
// с тремя companion-файлами актуальной (latest) версии:
// Источник: Gitea Packages (generic), хранилище `ratatoskr`. Каждая сборка
// публикуется в отдельную версию `commit-<sha7>` и содержит три файла:
// - <filename> сам бинарь
// - <filename>.version текстовый идентификатор версии (commit-<sha7>)
// - <filename>.sha256 hex-контрольная сумма бинаря
//
// Намеренно НЕ используется псевдо-версия `latest`: в Gitea при параллельных
// PUT (матрица CI: 6 платформ) она «переуказывается» на каждый снимок, и файлы
// одного релиза разъезжаются между разными версиями. Вместо этого новейшая
// версия определяется списком — GET /api/v1/packages/{owner}/generic/{package}
// отдаёт массив версий (id, version, created_at), берём новый `commit-*`.
//
// Модель обновления: «только по команде /update». Авто-режим (по расписанию
// и при старте) делает ТОЛЬКО Check и уведомляет владельца в чат — ничего не
// качает и не трогает работающий процесс.
@@ -15,10 +21,12 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -46,9 +54,9 @@ type Updater struct {
// Result — результат Check.
type Result struct {
// UpdateAvailable — true, если latest отличается от вшитой версии.
// UpdateAvailable — true, если новейшая версия отличается от вшитой.
UpdateAvailable bool
// Version — идентификатор свежей (latest) версии.
// Version — идентификатор новейшей применимой версии (commit-<sha7>).
Version string
// Err — ошибка проверки (U1), нефатальная. Если != nil, остальные поля —
// нулевые, и качать нельзя.
@@ -97,10 +105,17 @@ func Filename(goos, goarch string) string {
// PlatformFilename — имя бинаря для текущей платформы.
func PlatformFilename() string { return Filename(runtime.GOOS, runtime.GOARCH) }
// fileURL собирает URL companion-файла (версии/суммы) в хранилище latest.
func (u *Updater) fileURL(name string) string {
// fileURL собирает URL файла в хранилище конкретной версии (НЕ latest).
func (u *Updater) fileURL(version, name string) string {
base := strings.TrimRight(u.BaseURL, "/")
return base + "/api/packages/" + u.Owner + "/generic/" + u.Package + "/latest/" + name
return base + "/api/packages/" + url.PathEscape(u.Owner) + "/generic/" +
url.PathEscape(u.Package) + "/" + url.PathEscape(version) + "/" + url.PathEscape(name)
}
// versionsURL — URL листинга версий пакета.
func (u *Updater) versionsURL() string {
base := strings.TrimRight(u.BaseURL, "/")
return base + "/api/v1/packages/" + url.PathEscape(u.Owner) + "/generic/" + url.PathEscape(u.Package)
}
// httpGet скачивает файл по URL бэкенда. При Token непустом — Basic/токен-заголовок.
@@ -123,37 +138,68 @@ func (u *Updater) httpGet(url string) ([]byte, error) {
return io.ReadAll(resp.Body)
}
// Check определяет, есть ли более свежая версия в хранилище latest.
// Check определяет, есть ли более свежая версия в Gitea Packages.
// Не скачивает бинарь. Ошибка (U1) возвращается в Result.Err — вызывающий
// решает, логировать и пропустить.
func (u *Updater) Check(ctx context.Context) Result {
if err := ctx.Err(); err != nil {
return Result{Err: ue(U1, "check", err)}
}
ver, err := u.latestVersion(ctx)
ver, err := u.ResolveLatest(ctx)
if err != nil {
return Result{Err: ue(U1, "check", err)}
}
if ver == "" {
return Result{Err: ue(U1, "check", errors.New("latest version file empty"))}
return Result{Err: ue(U1, "check", errors.New("no applicable commit-* version found"))}
}
avail := u.CurrentVersion != "" && ver != u.CurrentVersion
return Result{UpdateAvailable: avail, Version: ver}
}
// latestVersion читает companion-файл <filename>.version хранилища latest.
func (u *Updater) latestVersion(ctx context.Context) (string, error) {
b, err := u.httpGet(u.fileURL(PlatformFilename() + ".version"))
// pkgVersion — одна запись из сплющенного листинга версий Gitea.
// GET /api/v1/packages/{owner}/generic/{package} возвращает массив таких:
// каждая запись (id, version, html_url) — отдельная версия пакета.
type pkgVersion struct {
ID int64 `json:"id"`
Ver string `json:"version"`
}
// ResolveLatest определяет идентификатор новейшей применимой версии пакета.
// Бинарь/метаданные читаем из КОНКРЕТНОЙ версии, а не из pseudo-`latest`,
// чтобы companion-файлы и бинарь всегда брались из одного снимка.
func (u *Updater) ResolveLatest(ctx context.Context) (string, error) {
b, err := u.httpGet(u.versionsURL())
if err != nil {
return "", err
}
return strings.TrimSpace(string(b)), nil
var vers []pkgVersion
if err := json.Unmarshal(b, &vers); err != nil {
return "", ue(U1, "list "+u.versionsURL(), err)
}
want := PlatformFilename()
// выбираем самую свежую версию (макс. id) класса commit-*, в которой есть бинарь.
best := ""
var bestID int64
for _, v := range vers {
if !strings.HasPrefix(v.Ver, "commit-") {
continue
}
// версия применима, только если в ней опубликован бинарь нашей платформы
if _, err := u.httpGet(u.fileURL(v.Ver, want)); err != nil {
continue
}
if v.ID > bestID {
bestID = v.ID
best = v.Ver
}
}
return best, nil
}
// Download скачивает бинарь latest во временный файл и возвращает путь к нему.
// Поток проверки: метаданные уже получены Check. Файл: <Dir>/.ratatoskr.<ver>.new.
// Download скачивает бинарь конкретной версии во временный файл и возвращает
// путь к нему. Файл: <Dir>/.ratatoskr.<ver>.new.
func (u *Updater) Download(ctx context.Context, version string) (string, error) {
b, err := u.httpGet(u.fileURL(PlatformFilename()))
b, err := u.httpGet(u.fileURL(version, PlatformFilename()))
if err != nil {
return "", err // уже U3
}
@@ -168,9 +214,9 @@ func (u *Updater) Download(ctx context.Context, version string) (string, error)
return tmp, nil
}
// latestSum256 читает companion-файл контрольной суммы latest.
func (u *Updater) latestSum256(ctx context.Context) (string, error) {
b, err := u.httpGet(u.fileURL(PlatformFilename() + ".sha256"))
// versionSum256 читает companion-файл контрольной суммы конкретной версии.
func (u *Updater) versionSum256(ctx context.Context, version string) (string, error) {
b, err := u.httpGet(u.fileURL(version, PlatformFilename()+".sha256"))
if err != nil {
return "", err
}
@@ -180,13 +226,13 @@ func (u *Updater) latestSum256(ctx context.Context) (string, error) {
// sha256hex — hex-сумма байтов.
func sha256hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
// Verify проверяет контрольную сумму скачанного файла против latest-метаданных.
func (u *Updater) Verify(ctx context.Context, file string) error {
// Verify проверяет контрольную сумму скачанного файла против .sha256 ТОЙ ЖЕ версии.
func (u *Updater) Verify(ctx context.Context, version, file string) error {
b, err := os.ReadFile(file)
if err != nil {
return ue(U4, "verify read", err)
}
sum, err := u.latestSum256(ctx)
sum, err := u.versionSum256(ctx, version)
if err != nil {
return err
}