Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: .
Expand Down
56 changes: 50 additions & 6 deletions src/lib/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -53,33 +57,73 @@ export function isNewerVersion(candidate: string, current: string) {
return false;
}

async function fetchLatestRelease(): Promise<Release | null> {
/**
* 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<boolean> {
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<boolean> {
const state = globalForUpdates.openrssRelease;
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) => {
Expand Down
60 changes: 53 additions & 7 deletions tests/updates.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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");
});
});
Loading