From 96e3360ca435dada6e5dbd1235d519e9993c298e Mon Sep 17 00:00:00 2001 From: Paulo Carinhena <63272749+paulocarinhena@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:31:08 -0300 Subject: [PATCH] =?UTF-8?q?Aviso=20de=20atualiza=C3=A7=C3=A3o=20espera=20a?= =?UTF-8?q?=20imagem=20Docker=20ser=20publicada?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release era criada no GitHub antes do workflow Docker terminar (~20 min). Nesse intervalo o openRSS já mostrava "nova versão", mas o botão Atualizar não fazia nada: o Watchtower ainda não achava imagem nova em latest. - updates.ts: só avisa quando a tag da versão existe no ghcr.io; enquanto a imagem está sendo construída, volta a checar em 15 min (não em 12 h). Instalações a partir do código não esperam a imagem. - docker.yml: a tag de versão também publica latest no mesmo push, e um build novo na main cancela o anterior, para um build atrasado de um commit antigo não sobrescrever latest. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docker.yml | 10 ++++++ src/lib/updates.ts | 56 +++++++++++++++++++++++++++++---- tests/updates.test.ts | 60 +++++++++++++++++++++++++++++++----- 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index addba91..4e46071 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -13,6 +13,13 @@ permissions: contents: read packages: write +# Vários pushes seguidos na main disputavam a tag "latest": o build que terminava por último vencia, +# mesmo sendo de um commit mais antigo. Um build por ref; na main o novo cancela o anterior +# (tags de versão nunca são canceladas). +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: ${{ github.ref == 'refs/heads/main' }} + jobs: # A imagem só é publicada se subir e responder no /api/health. smoke: @@ -63,6 +70,9 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable={{is_default_branch}} + # A tag de versão também publica "latest", no mesmo push: quando o app vê "0.1.6" no + # ghcr.io (src/lib/updates.ts), o Watchtower já encontra a versão nova em "latest". + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - uses: docker/build-push-action@v7 with: context: . diff --git a/src/lib/updates.ts b/src/lib/updates.ts index 22e9d80..19f7ee3 100644 --- a/src/lib/updates.ts +++ b/src/lib/updates.ts @@ -5,8 +5,12 @@ import pkg from "../../package.json"; export const APP_VERSION: string = pkg.version; const REPOSITORY = "CariSoftwareBR/openRSS"; +/** Imagem publicada pelo workflow Docker (ghcr.io), com uma tag por versão ("0.1.6"). */ +const IMAGE = "carisoftwarebr/openrss"; const CHECK_INTERVAL_MS = 12 * 3600_000; const RETRY_AFTER_FAILURE_MS = 3600_000; +/** Release publicada no GitHub, mas a imagem ainda está sendo construída: checa de novo logo. */ +const RETRY_WHILE_BUILDING_MS = 15 * 60_000; export type InstallMethod = "docker" | "umbrel" | "casaos" | "unraid" | "source"; @@ -53,24 +57,61 @@ export function isNewerVersion(candidate: string, current: string) { return false; } -async function fetchLatestRelease(): Promise { +/** + * A imagem da versão já está no ghcr.io? A release é criada no GitHub antes do workflow Docker + * terminar (~20 min, multi-arquitetura); até lá o Watchtower não acha nada novo para baixar. + * A tag da versão só aparece no registro depois que todas as plataformas foram enviadas. + */ +export async function imagePublished(tag: string): Promise { + const version = tag.replace(/^v/i, ""); + const tokenRes = await fetch(`https://ghcr.io/token?scope=repository:${IMAGE}:pull`, { + signal: AbortSignal.timeout(8000), + }); + if (!tokenRes.ok) throw new Error(`GHCR token ${tokenRes.status}`); + const { token } = (await tokenRes.json()) as { token?: string }; + const res = await fetch(`https://ghcr.io/v2/${IMAGE}/manifests/${encodeURIComponent(version)}`, { + method: "HEAD", + headers: { + ...(token ? { authorization: `Bearer ${token}` } : {}), + accept: [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + ].join(", "), + }, + signal: AbortSignal.timeout(8000), + }); + if (res.status === 404) return false; + if (!res.ok) throw new Error(`GHCR ${res.status}`); + return true; +} + +/** `building`: a release existe, mas a imagem dela ainda não foi publicada. */ +async function fetchLatestRelease(): Promise<{ release: Release | null; building: boolean }> { const res = await fetch(`https://api.github.com/repos/${REPOSITORY}/releases/latest`, { headers: { accept: "application/vnd.github+json", "user-agent": `openRSS/${APP_VERSION}` }, signal: AbortSignal.timeout(8000), }); if (!res.ok) throw new Error(`GitHub ${res.status}`); const data = (await res.json()) as { tag_name?: string; body?: string; html_url?: string; published_at?: string; draft?: boolean; prerelease?: boolean }; - if (!data.tag_name || data.draft || data.prerelease) return null; - return { + if (!data.tag_name || data.draft || data.prerelease) return { release: null, building: false }; + const release = { tag: data.tag_name, notes: (data.body ?? "").slice(0, 20_000), url: data.html_url ?? `https://github.com/${REPOSITORY}/releases`, publishedAt: data.published_at ?? null, }; + // Instalação a partir do código não depende da imagem; nas demais, só avisa quando dá para atualizar. + if (installMethod() !== "source" && isNewerVersion(release.tag, APP_VERSION) && !(await imagePublished(release.tag))) { + return { release: null, building: true }; + } + return { release, building: false }; } /** - * Atualiza o cache em segundo plano (uma busca por vez; no máximo a cada 12 h, 1 h após falha). + * Atualiza o cache em segundo plano (uma busca por vez; no máximo a cada 12 h, 1 h após falha, + * 15 min enquanto a imagem da nova versão ainda está sendo publicada). * Resolve com `false` quando o GitHub não respondeu (o cache anterior continua valendo). */ function refreshRelease(force = false): Promise { @@ -78,8 +119,11 @@ function refreshRelease(force = false): Promise { if (state?.pending) return state.pending; if (!force && state && Date.now() - state.checkedAt < CHECK_INTERVAL_MS) return Promise.resolve(true); const pending = fetchLatestRelease() - .then((release) => { - globalForUpdates.openrssRelease = { checkedAt: Date.now(), release }; + .then(({ release, building }) => { + globalForUpdates.openrssRelease = building + ? // Mantém o aviso anterior (se houver) e volta a olhar em 15 min, não em 12 h. + { checkedAt: Date.now() - CHECK_INTERVAL_MS + RETRY_WHILE_BUILDING_MS, release: state?.release ?? null } + : { checkedAt: Date.now(), release }; return true; }) .catch((err) => { diff --git a/tests/updates.test.ts b/tests/updates.test.ts index 6f41c83..a9f43d2 100644 --- a/tests/updates.test.ts +++ b/tests/updates.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { checkForUpdate, installMethod, isNewerVersion } from "@/lib/updates"; +import { checkForUpdate, imagePublished, installMethod, isNewerVersion } from "@/lib/updates"; describe("isNewerVersion", () => { it("compares semver tags with or without v", () => { @@ -25,20 +25,66 @@ describe("installMethod", () => { }); describe("checkForUpdate", () => { - afterEach(() => vi.unstubAllGlobals()); + const release = { tag_name: "v99.0.0", body: "notas", html_url: "https://example.test/r", published_at: "2026-11-01T00:00:00Z" }; + + /** GitHub responde a release; o ghcr.io responde `manifestStatus` para a tag da imagem. */ + function stubNetwork(manifestStatus: number) { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = String(input instanceof Request ? input.url : input); + if (url.startsWith("https://ghcr.io/token")) return Response.json({ token: "t" }); + if (url.startsWith("https://ghcr.io/v2/")) return new Response(null, { status: manifestStatus }); + return Response.json(release); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENRSS_INSTALL_METHOD; + delete (globalThis as { openrssRelease?: unknown }).openrssRelease; + }); it("throws when GitHub cannot be reached instead of reporting 'up to date'", async () => { vi.stubGlobal("fetch", vi.fn(async () => new Response("rate limited", { status: 403 }))); await expect(checkForUpdate()).rejects.toThrow(); }); - it("returns the newer release when GitHub answers", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => Response.json({ tag_name: "v99.0.0", body: "notas", html_url: "https://example.test/r", published_at: "2026-11-01T00:00:00Z" })), - ); + it("returns the newer release once its image is published", async () => { + const fetchMock = stubNetwork(200); const update = await checkForUpdate(); expect(update?.latest).toBe("99.0.0"); expect(update?.notes).toBe("notas"); + expect(fetchMock.mock.calls.map(([u]) => String(u))).toContain("https://ghcr.io/v2/carisoftwarebr/openrss/manifests/99.0.0"); + }); + + it("hides the release while its Docker image is still being built", async () => { + stubNetwork(404); + expect(await checkForUpdate()).toBeNull(); + }); + + it("throws when the registry cannot be checked", async () => { + stubNetwork(503); + await expect(checkForUpdate()).rejects.toThrow(); + }); + + it("does not wait for the image on source installs", async () => { + process.env.OPENRSS_INSTALL_METHOD = "source"; + const fetchMock = stubNetwork(404); + expect((await checkForUpdate())?.latest).toBe("99.0.0"); + expect(fetchMock.mock.calls.some(([u]) => String(u).includes("ghcr.io"))).toBe(false); + }); +}); + +describe("imagePublished", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("checks the version tag without the v prefix", async () => { + const fetchMock = vi.fn(async (input: string | URL | Request) => + String(input).includes("/token") ? Response.json({ token: "t" }) : new Response(null, { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + expect(await imagePublished("v0.1.6")).toBe(true); + expect(String(fetchMock.mock.calls[1][0])).toBe("https://ghcr.io/v2/carisoftwarebr/openrss/manifests/0.1.6"); }); });