From 63668a0f693c5ced97c4669bc9abf8ee893bbcc3 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 16 Aug 2026 13:05:37 +0500 Subject: [PATCH] =?UTF-8?q?fix(update):=20=D1=83=D0=B1=D1=80=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D1=81=D0=B5=D0=B2=D0=B4=D0=BE-=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D1=8E=20latest,=20=D0=B2=D1=81=D1=91=20?= =?UTF-8?q?=D0=B2=20commit-?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea: при параллельных PUT матрицы (6 платформ) псевдо-версия 'latest' переуказывается на каждый снимок, companion-файлы и бинарь разъезжаются между версиями — checksum U4 ломался. - CI: публикует бинарь+.version+.sha256 в (commit-), latest не используется - Updater.ResolveLatest: новейшая commit-* версия из листинга /api/v1/packages/{owner}/generic/{package}, берём макс id у которой есть бинарь текущей платформы - Download/Verify/checksum читают ТУ ЖЕ version, а не latest --- .gitea/workflows/ci.yaml | 42 ++++------------ internal/app/app.go | 2 +- internal/update/update.go | 92 +++++++++++++++++++++++++--------- internal/update/update_test.go | 62 +++++++++++++++++------ 4 files changed, 127 insertions(+), 71 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 9c87e9e..6caadd1 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -66,26 +66,6 @@ jobs: run: ls -lh "$FILENAME" - name: Upload to Gitea Packages - if: gitea.ref == 'refs/heads/main' - run: | - curl -sS -X PUT \ - -H "Authorization: token ${{ secrets.TC_GITEA_TOKEN }}" \ - -H "Content-Type: application/octet-stream" \ - "${{ vars.GIT_MAIN_URL }}/api/packages/${{ gitea.actor }}/generic/ratatoskr/${VERSION}/${FILENAME}" \ - --data-binary @${FILENAME} - echo "Uploaded ${VERSION}/${FILENAME}" - - - name: Update latest tag - if: gitea.ref == 'refs/heads/main' - run: | - curl -sS -X PUT \ - -H "Authorization: token ${{ secrets.TC_GITEA_TOKEN }}" \ - -H "Content-Type: application/octet-stream" \ - "${{ vars.GIT_MAIN_URL }}/api/packages/${{ gitea.actor }}/generic/ratatoskr/latest/${FILENAME}" \ - --data-binary @${FILENAME} - echo "Updated 'latest/${FILENAME}'" - - - name: Publish companion files (version + sha256) if: gitea.ref == 'refs/heads/main' run: | SHA256_HEX=$(sha256sum "${FILENAME}" | awk '{print $1}') @@ -93,14 +73,14 @@ jobs: printf '%s' "${VERSION}" > "${FILENAME}.version" # .sha256 — контрольная сумма бинаря (проверка U4) printf '%s' "${SHA256_HEX}" > "${FILENAME}.sha256" - curl -sS -X PUT \ - -H "Authorization: token ${{ secrets.TC_GITEA_TOKEN }}" \ - -H "Content-Type: text/plain" \ - "${{ vars.GIT_MAIN_URL }}/api/packages/${{ gitea.actor }}/generic/ratatoskr/latest/${FILENAME}.version" \ - --data-binary @${FILENAME}.version - curl -sS -X PUT \ - -H "Authorization: token ${{ secrets.TC_GITEA_TOKEN }}" \ - -H "Content-Type: text/plain" \ - "${{ vars.GIT_MAIN_URL }}/api/packages/${{ gitea.actor }}/generic/ratatoskr/latest/${FILENAME}.sha256" \ - --data-binary @${FILENAME}.sha256 - echo "Published companion files for latest/${FILENAME}" \ No newline at end of file + # Всё публикуется в КОНКРЕТНУЮ версию ${VERSION}, НЕ в псевдо-version "latest". + # "latest" в Gitea при параллельных PUT матрицы (6 платформ) переуказывается + # на каждый снимок — companion-файлы и бинарь разъезжаются, ломая checksum (U4). + for f in "${FILENAME}" "${FILENAME}.version" "${FILENAME}.sha256"; do + curl -sS -X PUT \ + -H "Authorization: token ${{ secrets.TC_GITEA_TOKEN }}" \ + -H "Content-Type: application/octet-stream" \ + "${{ vars.GIT_MAIN_URL }}/api/packages/${{ gitea.actor }}/generic/ratatoskr/${VERSION}/${f}" \ + --data-binary @"${f}" + done + echo "Published ${VERSION}/{binary,.version,.sha256}" \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index b8410f3..bae665e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -406,7 +406,7 @@ func (a *App) handleUpdateCmd(ctx context.Context, uid chat.UserID) { a.send(ctx, uid, "Ошибка скачивания: "+err.Error()) return } - if err := a.Updater.Verify(ctx, file); err != nil { + if err := a.Updater.Verify(ctx, res.Version, file); err != nil { a.send(ctx, uid, "Обновление отклонено (контрольная сумма): "+err.Error()) return } diff --git a/internal/update/update.go b/internal/update/update.go index 2e54636..0aed2aa 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -1,11 +1,17 @@ // Package update — система автообновления бинаря ratatoskr. // -// Источник: Gitea Packages (generic), хранилище `ratatoskr`, -// с тремя companion-файлами актуальной (latest) версии: +// Источник: Gitea Packages (generic), хранилище `ratatoskr`. Каждая сборка +// публикуется в отдельную версию `commit-` и содержит три файла: // - сам бинарь // - .version текстовый идентификатор версии (commit-) // - .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-). 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-файл .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. Файл: /.ratatoskr..new. +// Download скачивает бинарь конкретной версии во временный файл и возвращает +// путь к нему. Файл: /.ratatoskr..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 } diff --git a/internal/update/update_test.go b/internal/update/update_test.go index f4fa969..c53d28b 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -2,6 +2,7 @@ package update import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -11,24 +12,53 @@ import ( "testing" ) -// mockGitea поднимает httptest-сервер с бинарём, .version и .sha256. +// mockGitea поднимает httptest-сервер: листинг версий `/api/v1/packages/...` +// и файлы конкретных версий `/api/packages/.../{version}/{filename}`. +// version — имя новейшей версии (id бóльший), У которой есть бинарь+метаданные; +// старые версии бинаря не содержат и отсеются ResolveLatest. func mockGitea(t *testing.T, bin []byte, version, checksum string) *httptest.Server { t.Helper() name := PlatformFilename() + // старый commit, чтобы проверить, что Resolve берёт именно новейший. + old := "commit-old" + version[len("commit-"):] + fileSets := map[string]map[string][]byte{ + version: { + name: bin, + name + ".version": []byte(version), + name + ".sha256": []byte(checksum), + }, + } + versions := []map[string]any{ + {"id": 1, "version": old}, + {"id": 2, "version": version}, + } mux := http.NewServeMux() - mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) { - base := name - switch { - case strings.HasSuffix(r.URL.Path, ".version"): - base = name + ".version" - _, _ = w.Write([]byte(version)) - case strings.HasSuffix(r.URL.Path, ".sha256"): - base = name + ".sha256" - _, _ = w.Write([]byte(checksum)) - default: - _, _ = w.Write(bin) // сам бинарь + mux.HandleFunc("/api/v1/packages/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/generic/ratatoskr") { + http.NotFound(w, r) + return } - _ = base + _ = json.NewEncoder(w).Encode(versions) + }) + mux.HandleFunc("/api/packages/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method", http.StatusMethodNotAllowed) + return + } + // путь: /api/packages/{owner}/generic/{pkg}/{version}/{filename} + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 6 { + http.NotFound(w, r) + return + } + ver := parts[len(parts)-2] + fileName := parts[len(parts)-1] + body, ok := fileSets[ver][fileName] + if !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write(body) }) return httptest.NewServer(mux) } @@ -113,7 +143,7 @@ func TestDownload_And_Verify_Good(t *testing.T) { if !strings.Contains(file, ".new") { t.Errorf("temp file %q should end with .new", filepath.Base(file)) } - if err := u.Verify(context.Background(), file); err != nil { + if err := u.Verify(context.Background(), "commit-new12345", file); err != nil { t.Fatalf("Verify err = %v", err) } // .new лежит в каталоге @@ -128,11 +158,11 @@ func TestVerify_ChecksumMismatch(t *testing.T) { srv := mockGitea(t, bin, "commit-x", sha256hex([]byte("differente-content"))) t.Cleanup(srv.Close) u := &Updater{BaseURL: srv.URL, Owner: "k", Package: "p", Dir: t.TempDir()} - file := filepath.Join(u.Dir, ".ratatoskr.x.new") + file := filepath.Join(u.Dir, ".ratatoskr.commit-x.new") if err := os.WriteFile(file, []byte("A"), 0o755); err != nil { t.Fatal(err) } - err := u.Verify(context.Background(), file) + err := u.Verify(context.Background(), "commit-x", file) if err == nil { t.Fatal("expected checksum mismatch error") }