From fab2d63c8ab655a91c21d7d720d1143fc42ad822 Mon Sep 17 00:00:00 2001
From: Rory Byrne
Date: Sun, 16 Aug 2026 17:04:41 +0100
Subject: [PATCH 1/2] feat(dashboard): OSA version upgrade flow in archive
settings (#222)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Archive owners see their OSA version, what's newer, and what changed —
and upgrade from the settings panel instead of a raw API call.
Platform mode (the full flow):
- UpgradeSection above Redeploy: pin + availability chip (Up to date /
Update available → vX.Y.Z), blocked-with-reason while deploying, a
deprecated-pin nudge, a withdrawn-pin 'upgrade required' alert (the
cloud refuses redeploys of withdrawn versions — surfaced proactively),
and the honest failed-upgrade state: the pin moves when the upgrade
starts, so a failed deployment says 'retrying deploys {pin}', never
implying rollback.
- UpgradeDialog: from → to with a picker when several supported targets
exist (default = newest, 'recommended' on the registry default),
release notes rendered inline and stacked oldest-first for
multi-version jumps + a GitHub compare link, a plain-language
migrations/pause warning, and a forward-only acknowledgment checkbox
(new ui/Checkbox primitive) gating the confirm.
- useUpgradeArchive mirrors useCreateArchive's handoff: pre-seed the
status cache from the 202, then route into /deploying/{id} — the
existing screen owns progress. invalid_state maps to 'A deployment is
already in progress.'; validation_error shows the server's precise
forward-only message verbatim.
- GET /api/releases: cached (10 min) server-side GitHub notes proxy,
session-guarded; range (from, to], drafts/prereleases skipped; 502
degrades the dialog to the registry notes_url link-out.
Self-host mode (awareness, deliberately no button — server and
dashboard share one OSA_IMAGE_VERSION in compose, so the dashboard
cannot upgrade the server without restarting itself):
- Version row in 'Manage this node' + a registry check (ghcr tags list,
cached ~1h, riding /api/node; null on failure degrades silently) and,
when behind, the exact copyable command:
osa start --osa-version vX.Y.Z.
- compareOsaVersions normalizes the health endpoint's bare 0.0.11
against v-prefixed registry tags.
Wire layer: osa_version_pin joins wireArchive (always present, per the
cloud DTO); wireOsaVersion + decoder with strict status validation; both
service impls (real + mock) extended — the mock registry seeds
supported/deprecated versions and reproduces the cloud's 400/422 error
contract for tests.
15 new tests (7 section/dialog, 3 self-host, 5 releases route); full
suite 222 green; lint + production build clean.
---
apps/dashboard/src/api/amacrin/mock.ts | 89 +++++++++
apps/dashboard/src/api/amacrin/real.ts | 17 ++
apps/dashboard/src/api/amacrin/service.ts | 9 +
apps/dashboard/src/api/amacrin/wire/decode.ts | 24 +++
.../dashboard/src/api/amacrin/wire/schemas.ts | 15 ++
apps/dashboard/src/api/osa/wire/schemas.ts | 1 +
apps/dashboard/src/app/api/node/route.ts | 17 +-
.../src/app/api/releases/route.test.ts | 95 +++++++++
apps/dashboard/src/app/api/releases/route.ts | 52 +++++
apps/dashboard/src/domain/archive.ts | 6 +
apps/dashboard/src/domain/node.ts | 2 +
apps/dashboard/src/domain/osa-version.ts | 66 +++++++
.../archive-settings/SettingsPanel.tsx | 4 +
.../SelfHostVersion.module.css | 42 ++++
.../archive-upgrade/SelfHostVersion.test.tsx | 69 +++++++
.../archive-upgrade/SelfHostVersion.tsx | 52 +++++
.../archive-upgrade/UpgradeDialog.module.css | 86 ++++++++
.../archive-upgrade/UpgradeDialog.tsx | 186 ++++++++++++++++++
.../archive-upgrade/UpgradeSection.module.css | 47 +++++
.../archive-upgrade/UpgradeSection.test.tsx | 163 +++++++++++++++
.../archive-upgrade/UpgradeSection.tsx | 101 ++++++++++
.../src/features/archive-upgrade/keys.ts | 5 +
.../archive-upgrade/useOsaVersions.ts | 17 ++
.../archive-upgrade/useReleaseNotes.ts | 34 ++++
.../archive-upgrade/useUpgradeArchive.ts | 31 +++
.../src/features/archives/local-archive.ts | 3 +-
.../deployments/DeployingScreen.test.tsx | 1 +
.../src/mocks/fixtures/archive.deploying.json | 3 +-
.../src/mocks/fixtures/archive.error.json | 9 +-
.../src/mocks/fixtures/archive.running.json | 9 +-
.../src/mocks/fixtures/osa-versions.json | 29 +++
apps/dashboard/src/mocks/handlers.ts | 5 +
apps/dashboard/src/server/ghcr.ts | 70 +++++++
apps/dashboard/src/server/releases.ts | 53 +++++
apps/dashboard/src/test/factories.ts | 1 +
.../src/ui/checkbox/Checkbox.module.css | 27 +++
apps/dashboard/src/ui/checkbox/Checkbox.tsx | 20 ++
apps/dashboard/src/ui/index.ts | 1 +
38 files changed, 1450 insertions(+), 11 deletions(-)
create mode 100644 apps/dashboard/src/app/api/releases/route.test.ts
create mode 100644 apps/dashboard/src/app/api/releases/route.ts
create mode 100644 apps/dashboard/src/domain/osa-version.ts
create mode 100644 apps/dashboard/src/features/archive-upgrade/SelfHostVersion.module.css
create mode 100644 apps/dashboard/src/features/archive-upgrade/SelfHostVersion.test.tsx
create mode 100644 apps/dashboard/src/features/archive-upgrade/SelfHostVersion.tsx
create mode 100644 apps/dashboard/src/features/archive-upgrade/UpgradeDialog.module.css
create mode 100644 apps/dashboard/src/features/archive-upgrade/UpgradeDialog.tsx
create mode 100644 apps/dashboard/src/features/archive-upgrade/UpgradeSection.module.css
create mode 100644 apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx
create mode 100644 apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx
create mode 100644 apps/dashboard/src/features/archive-upgrade/keys.ts
create mode 100644 apps/dashboard/src/features/archive-upgrade/useOsaVersions.ts
create mode 100644 apps/dashboard/src/features/archive-upgrade/useReleaseNotes.ts
create mode 100644 apps/dashboard/src/features/archive-upgrade/useUpgradeArchive.ts
create mode 100644 apps/dashboard/src/mocks/fixtures/osa-versions.json
create mode 100644 apps/dashboard/src/server/ghcr.ts
create mode 100644 apps/dashboard/src/server/releases.ts
create mode 100644 apps/dashboard/src/ui/checkbox/Checkbox.module.css
create mode 100644 apps/dashboard/src/ui/checkbox/Checkbox.tsx
diff --git a/apps/dashboard/src/api/amacrin/mock.ts b/apps/dashboard/src/api/amacrin/mock.ts
index a242382..c1afa34 100644
--- a/apps/dashboard/src/api/amacrin/mock.ts
+++ b/apps/dashboard/src/api/amacrin/mock.ts
@@ -9,6 +9,8 @@ import { ApiError, SlugTakenError } from "@/api/http/errors";
import type { Archive } from "@/domain/archive";
import type { Build, ComponentBuild } from "@/domain/build";
import type { Deployment } from "@/domain/deployment";
+import type { OsaVersion } from "@/domain/osa-version";
+import { compareOsaVersions } from "@/domain/osa-version";
import type { Organisation } from "@/domain/organisation";
import type { BuildListItem, OrgMember } from "@/domain/tenant";
import type { Session } from "@/domain/user";
@@ -32,6 +34,32 @@ function notFound(what: string): ApiError {
/** The OSA server version the mock control plane provisions. */
const MOCK_OSA_VERSION = "v0.0.9";
+/** The mock registry: one older deprecated, the pin, and two upgrades. */
+const MOCK_OSA_VERSIONS: OsaVersion[] = [
+ {
+ version: "v0.0.11",
+ status: "supported",
+ isDefault: true,
+ notesUrl:
+ "https://github.com/opensciencearchive/server/releases/tag/v0.0.11",
+ },
+ {
+ version: "v0.0.10",
+ status: "supported",
+ isDefault: false,
+ notesUrl:
+ "https://github.com/opensciencearchive/server/releases/tag/v0.0.10",
+ },
+ {
+ version: "v0.0.9",
+ status: "supported",
+ isDefault: false,
+ notesUrl:
+ "https://github.com/opensciencearchive/server/releases/tag/v0.0.9",
+ },
+ { version: "v0.0.8", status: "deprecated", isDefault: false, notesUrl: null },
+];
+
/** The signed-in user, as a member of any org they create. */
const SELF_MEMBER: OrgMember = {
userId: "user_mock000001",
@@ -152,6 +180,10 @@ export class MockAmacrinService implements AmacrinService {
organisationId: orgId,
name: input.name,
slug: input.slug,
+ // New archives are born on the registry default (the cloud rule).
+ osaVersionPin:
+ MOCK_OSA_VERSIONS.find((v) => v.isDefault)?.version ??
+ MOCK_OSA_VERSION,
domain: `${input.slug}.amacr.in`,
status: { kind: "deploying" },
orcidAdmins: input.adminOrcidIds,
@@ -217,6 +249,62 @@ export class MockAmacrinService implements AmacrinService {
return Promise.resolve([state.deployment, ...state.past]);
}
+ listOsaVersions(): Promise {
+ return Promise.resolve([...MOCK_OSA_VERSIONS]);
+ }
+
+ upgradeArchive(archiveId: string, toVersion: string): Promise {
+ const state = this.archives.get(archiveId);
+ if (!state) return Promise.reject(notFound("archive"));
+ const target = MOCK_OSA_VERSIONS.find((v) => v.version === toVersion);
+ if (!target) return Promise.reject(notFound("OSA version"));
+ if (target.status !== "supported") {
+ return Promise.reject(
+ new ApiError({
+ status: 400,
+ code: "validation_error",
+ message: `OSA version '${toVersion}' is ${target.status} — not an upgrade target`,
+ }),
+ );
+ }
+ if (compareOsaVersions(toVersion, state.archive.osaVersionPin) <= 0) {
+ return Promise.reject(
+ new ApiError({
+ status: 400,
+ code: "validation_error",
+ message: `'${toVersion}' is not newer than the archive's current version '${state.archive.osaVersionPin}' — upgrades are forward-only`,
+ }),
+ );
+ }
+ if (state.archive.status.kind === "deploying") {
+ return Promise.reject(
+ new ApiError({
+ status: 422,
+ code: "invalid_state",
+ message: "deployment already in progress for this archive",
+ }),
+ );
+ }
+ // The pin moves WITH the deployment start — mirroring the cloud tx.
+ state.archive = {
+ ...state.archive,
+ osaVersionPin: toVersion,
+ status: { kind: "deploying" },
+ updatedAt: T0,
+ };
+ state.past = [state.deployment, ...state.past];
+ state.deployment = {
+ id: this.mintId("deploy"),
+ archiveId,
+ provider: "aws_eks",
+ status: { kind: "pending" },
+ osaVersion: null,
+ startedAt: new Date(state.past[0]!.startedAt.getTime() + 60_000),
+ };
+ state.pollsUntilAdvance = 2;
+ return Promise.resolve(state.deployment);
+ }
+
destroyArchive(
archiveId: string,
opts?: { force?: boolean },
@@ -402,6 +490,7 @@ export class MockAmacrinService implements AmacrinService {
const archive: Archive = {
...args,
domain: `${args.slug}.amacr.in`,
+ osaVersionPin: MOCK_OSA_VERSION,
deploymentConfig: {
provider: "aws_eks",
region: "eu-west-1",
diff --git a/apps/dashboard/src/api/amacrin/real.ts b/apps/dashboard/src/api/amacrin/real.ts
index 8a8258a..6028f3d 100644
--- a/apps/dashboard/src/api/amacrin/real.ts
+++ b/apps/dashboard/src/api/amacrin/real.ts
@@ -3,6 +3,7 @@ import { ApiError, SlugTakenError } from "@/api/http/errors";
import type { Archive } from "@/domain/archive";
import type { Build } from "@/domain/build";
import type { Deployment } from "@/domain/deployment";
+import type { OsaVersion } from "@/domain/osa-version";
import type { Organisation } from "@/domain/organisation";
import type { BuildListItem, OrgMember } from "@/domain/tenant";
import type { Session } from "@/domain/user";
@@ -19,6 +20,7 @@ import {
decodeOrganisation,
decodeOrganisationList,
decodeSession,
+ decodeOsaVersionList,
} from "./wire/decode";
import type {
AmacrinService,
@@ -148,6 +150,21 @@ export class RealAmacrinService implements AmacrinService {
);
}
+ async listOsaVersions(): Promise {
+ return decodeOsaVersionList(await this.client.get("/api/v1/osa-versions"));
+ }
+
+ async upgradeArchive(
+ archiveId: string,
+ toVersion: string,
+ ): Promise {
+ return decodeDeployment(
+ await this.client.post(`/api/v1/archives/${archiveId}/upgrade`, {
+ to_version: toVersion,
+ }),
+ );
+ }
+
async destroyArchive(
archiveId: string,
opts?: { force?: boolean },
diff --git a/apps/dashboard/src/api/amacrin/service.ts b/apps/dashboard/src/api/amacrin/service.ts
index d7a71e1..4b37041 100644
--- a/apps/dashboard/src/api/amacrin/service.ts
+++ b/apps/dashboard/src/api/amacrin/service.ts
@@ -8,6 +8,7 @@
import type { Archive } from "@/domain/archive";
import type { Build } from "@/domain/build";
import type { Deployment } from "@/domain/deployment";
+import type { OsaVersion } from "@/domain/osa-version";
import type { Organisation } from "@/domain/organisation";
import type { BuildListItem, OrgMember } from "@/domain/tenant";
import type { Session } from "@/domain/user";
@@ -64,6 +65,14 @@ export interface AmacrinService {
getDeploymentStatus(archiveId: string): Promise;
/** GET /archives/{id}/deployments — deployment history, newest first. */
listDeployments(archiveId: string): Promise;
+ /** GET /osa-versions — the registry, newest first (#222). */
+ listOsaVersions(): Promise;
+ /**
+ * POST /archives/{id}/upgrade — move the version pin, forward-only (202).
+ * The pin moves in the same transaction that starts the deployment: a
+ * failed upgrade deployment retries at the NEW version, never rolls back.
+ */
+ upgradeArchive(archiveId: string, toVersion: string): Promise;
/** POST /archives/{id}/destroy — the only removal path (Owner only). */
destroyArchive(
archiveId: string,
diff --git a/apps/dashboard/src/api/amacrin/wire/decode.ts b/apps/dashboard/src/api/amacrin/wire/decode.ts
index 270981a..4cc0098 100644
--- a/apps/dashboard/src/api/amacrin/wire/decode.ts
+++ b/apps/dashboard/src/api/amacrin/wire/decode.ts
@@ -24,6 +24,7 @@ import type {
import type { Deployment, DeploymentStatus } from "@/domain/deployment";
import { type Organisation, isRole } from "@/domain/organisation";
import type { BuildListItem, OrgMember } from "@/domain/tenant";
+import type { OsaVersion } from "@/domain/osa-version";
import type { Session } from "@/domain/user";
import {
@@ -40,6 +41,7 @@ import {
wireOrgMemberList,
wireOrganisation,
wireOrganisationList,
+ wireOsaVersionList,
} from "./schemas";
export class DecodeError extends Error {
@@ -175,6 +177,7 @@ export function decodeArchive(raw: unknown): Archive {
domain: wire.domain,
status: toArchiveStatus(wire.status, wire.error_message),
orcidAdmins: wire.config.auth?.admins?.orcid ?? [],
+ osaVersionPin: wire.osa_version_pin,
deploymentConfig,
createdAt: parseDate(wire.created_at, "archive"),
updatedAt: parseDate(wire.updated_at, "archive"),
@@ -364,3 +367,24 @@ export function decodeBuildList(raw: unknown): BuildListItem[] {
createdAt: parseDate(wire.created_at, "build"),
}));
}
+
+// ---------------------------------------------------------------------------
+// OSA versions (#222)
+// ---------------------------------------------------------------------------
+
+const OSA_VERSION_STATUSES = ["supported", "deprecated", "withdrawn"] as const;
+
+export function decodeOsaVersionList(raw: unknown): OsaVersion[] {
+ return parse(wireOsaVersionList, raw, "osa-versions").map((wire) => {
+ const status = OSA_VERSION_STATUSES.find((s) => s === wire.status);
+ if (!status) {
+ throw new DecodeError(`unknown OSA version status "${wire.status}"`);
+ }
+ return {
+ version: wire.version,
+ status,
+ isDefault: wire.is_default,
+ notesUrl: wire.notes_url ?? null,
+ };
+ });
+}
diff --git a/apps/dashboard/src/api/amacrin/wire/schemas.ts b/apps/dashboard/src/api/amacrin/wire/schemas.ts
index b693526..315fae5 100644
--- a/apps/dashboard/src/api/amacrin/wire/schemas.ts
+++ b/apps/dashboard/src/api/amacrin/wire/schemas.ts
@@ -57,6 +57,9 @@ export const wireArchive = z.object({
domain: z.string(),
config: wireArchiveConfig,
status: z.string(),
+ /** Desired version (always present); deployed version rides osa_version. */
+ osa_version_pin: z.string(),
+ osa_version: z.string().nullish(),
error_message: z.string().nullish(),
created_at: z.string(),
updated_at: z.string(),
@@ -64,6 +67,18 @@ export const wireArchive = z.object({
export const wireArchiveList = z.array(wireArchive);
+export const wireOsaVersion = z.object({
+ version: z.string(),
+ image_digest: z.string().nullish(),
+ status: z.string(),
+ is_default: z.boolean(),
+ released_at: z.string().nullish(),
+ registered_at: z.string(),
+ notes_url: z.string().nullish(),
+});
+
+export const wireOsaVersionList = z.array(wireOsaVersion);
+
export const wireDeployment = z.object({
id: z.string(),
archive_id: z.string(),
diff --git a/apps/dashboard/src/api/osa/wire/schemas.ts b/apps/dashboard/src/api/osa/wire/schemas.ts
index 010a1f4..88d49f9 100644
--- a/apps/dashboard/src/api/osa/wire/schemas.ts
+++ b/apps/dashboard/src/api/osa/wire/schemas.ts
@@ -154,4 +154,5 @@ export const wireNodeOverview = z.object({
status: z.enum(["ready", "degraded", "unknown"]),
records: z.number().nullable(),
schemas: z.number(),
+ latestOsaVersion: z.string().nullable().default(null),
});
diff --git a/apps/dashboard/src/app/api/node/route.ts b/apps/dashboard/src/app/api/node/route.ts
index 5381c52..8ff4b16 100644
--- a/apps/dashboard/src/app/api/node/route.ts
+++ b/apps/dashboard/src/app/api/node/route.ts
@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
+import { latestOsaVersion } from "@/server/ghcr";
import { osaApiUrl, sessionSecret } from "@/server/env";
import { SESSION_COOKIE, readSession } from "@/server/session";
@@ -38,10 +39,17 @@ export async function GET(req: NextRequest): Promise {
const base = osaApiUrl();
const auth = { authorization: `Bearer ${session.osaToken}` };
- const [discRes, readyRes, statsRes] = await Promise.allSettled([
- fetch(`${base}/`, { headers: { accept: "application/json" } }),
- fetch(`${base}/api/v1/ready`, { headers: { accept: "application/json" } }),
- fetch(`${base}/api/v1/stats`, { headers: { ...auth, accept: "application/json" } }),
+ // Update awareness (#222) rides along: latest published release from the
+ // registry, cached ~1h, null on failure — never blocks the overview.
+ const [[discRes, readyRes, statsRes], latest] = await Promise.all([
+ Promise.allSettled([
+ fetch(`${base}/`, { headers: { accept: "application/json" } }),
+ fetch(`${base}/api/v1/ready`, { headers: { accept: "application/json" } }),
+ fetch(`${base}/api/v1/stats`, {
+ headers: { ...auth, accept: "application/json" },
+ }),
+ ]),
+ latestOsaVersion(),
]);
const discovery = await json(discRes);
@@ -63,5 +71,6 @@ export async function GET(req: NextRequest): Promise {
: "unknown",
records: typeof stats.records === "number" ? stats.records : null,
schemas: Array.isArray(schemas) ? schemas.length : 0,
+ latestOsaVersion: latest,
});
}
diff --git a/apps/dashboard/src/app/api/releases/route.test.ts b/apps/dashboard/src/app/api/releases/route.test.ts
new file mode 100644
index 0000000..fa9466e
--- /dev/null
+++ b/apps/dashboard/src/app/api/releases/route.test.ts
@@ -0,0 +1,95 @@
+// @vitest-environment node
+import { NextRequest } from "next/server";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createPlatformSessionValue } from "@/server/platform-session";
+import { resetReleasesCache } from "@/server/releases";
+import { SESSION_COOKIE } from "@/server/session";
+
+import { GET } from "./route";
+
+const SECRET = "test-session-secret-test-session-secret!";
+
+function githubRelease(tag: string, over?: Record) {
+ return {
+ tag_name: tag,
+ name: `Release ${tag}`,
+ body: `notes for ${tag}`,
+ html_url: `https://github.com/opensciencearchive/server/releases/tag/${tag}`,
+ draft: false,
+ prerelease: false,
+ ...over,
+ };
+}
+
+async function request(query: string): Promise {
+ const cookie = await createPlatformSessionValue(
+ { accessToken: "t", refreshToken: "r" },
+ SECRET,
+ );
+ return new NextRequest(`http://dash.test/api/releases?${query}`, {
+ headers: { cookie: `${SESSION_COOKIE}=${cookie}` },
+ });
+}
+
+describe("GET /api/releases", () => {
+ beforeEach(() => {
+ vi.stubEnv("NEXT_PUBLIC_IS_PLATFORM", "true");
+ vi.stubEnv("SESSION_SECRET", SECRET);
+ resetReleasesCache();
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.restoreAllMocks();
+ });
+
+ it("returns releases in (from, to], oldest first, skipping drafts", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ Response.json([
+ githubRelease("v0.0.12"),
+ githubRelease("v0.0.11"),
+ githubRelease("v0.0.10"),
+ githubRelease("v0.0.13", { draft: true }),
+ ]),
+ );
+
+ const res = await GET(await request("from=v0.0.10&to=v0.0.12"));
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { releases: { version: string }[] };
+ expect(body.releases.map((r) => r.version)).toEqual([
+ "v0.0.11",
+ "v0.0.12",
+ ]);
+ });
+
+ it("serves from cache on the second call", async () => {
+ const spy = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValue(Response.json([githubRelease("v0.0.12")]));
+
+ await GET(await request("from=v0.0.11&to=v0.0.12"));
+ await GET(await request("from=v0.0.11&to=v0.0.12"));
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it("degrades to 502 when GitHub is unavailable", async () => {
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("rate limited", { status: 403 }),
+ );
+ const res = await GET(await request("from=v0.0.10&to=v0.0.12"));
+ expect(res.status).toBe(502);
+ });
+
+ it("rejects an invalid target version", async () => {
+ const res = await GET(await request("to=latest"));
+ expect(res.status).toBe(400);
+ });
+
+ it("requires a session", async () => {
+ const res = await GET(
+ new NextRequest("http://dash.test/api/releases?to=v0.0.12"),
+ );
+ expect(res.status).toBe(401);
+ });
+});
diff --git a/apps/dashboard/src/app/api/releases/route.ts b/apps/dashboard/src/app/api/releases/route.ts
new file mode 100644
index 0000000..3498b21
--- /dev/null
+++ b/apps/dashboard/src/app/api/releases/route.ts
@@ -0,0 +1,52 @@
+import { NextResponse, type NextRequest } from "next/server";
+
+import { isPlatformFromEnv } from "@/api/config";
+import { compareOsaVersions } from "@/domain/osa-version";
+import { sessionSecret } from "@/server/env";
+import { readPlatformSession } from "@/server/platform-session";
+import { RELEASE_TAG, publishedReleases } from "@/server/releases";
+import { SESSION_COOKIE, readSession } from "@/server/session";
+
+export const runtime = "nodejs";
+
+/**
+ * BFF: release notes for the upgrade dialog (#222).
+ *
+ * `GET /api/releases?from=v0.0.10&to=v0.0.12` → the notes of every release in
+ * `(from, to]`, oldest first, so multi-version jumps read chronologically.
+ * GitHub failure is a 502 the dialog degrades on (registry `notes_url`
+ * link-out).
+ */
+
+async function authorized(req: NextRequest): Promise {
+ const cookie = req.cookies.get(SESSION_COOKIE)?.value;
+ if (isPlatformFromEnv()) {
+ return (await readPlatformSession(cookie, sessionSecret())) !== null;
+ }
+ return (await readSession(cookie, sessionSecret())) !== null;
+}
+
+export async function GET(req: NextRequest): Promise {
+ if (!(await authorized(req))) {
+ return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
+ }
+
+ const from = req.nextUrl.searchParams.get("from") ?? "";
+ const to = req.nextUrl.searchParams.get("to") ?? "";
+ if (!RELEASE_TAG.test(to)) {
+ return NextResponse.json({ error: "invalid_range" }, { status: 400 });
+ }
+
+ try {
+ const releases = (await publishedReleases())
+ .filter(
+ (r) =>
+ compareOsaVersions(r.version, to) <= 0 &&
+ (from === "" || compareOsaVersions(r.version, from) > 0),
+ )
+ .sort((a, b) => compareOsaVersions(a.version, b.version));
+ return NextResponse.json({ releases });
+ } catch {
+ return NextResponse.json({ error: "github_unavailable" }, { status: 502 });
+ }
+}
diff --git a/apps/dashboard/src/domain/archive.ts b/apps/dashboard/src/domain/archive.ts
index a4395f2..6337440 100644
--- a/apps/dashboard/src/domain/archive.ts
+++ b/apps/dashboard/src/domain/archive.ts
@@ -38,6 +38,12 @@ export interface Archive {
status: ArchiveStatus;
/** Administrator ORCID iDs from the non-secret auth config. */
orcidAdmins: string[];
+ /**
+ * Desired OSA server version (the registry pin). The DEPLOYED version
+ * lives on the latest Deployment; pin ≠ deployed means an upgrade is in
+ * flight or its deployment failed (the pin moves first, by design).
+ */
+ osaVersionPin: string;
deploymentConfig: ArchiveDeploymentConfig | null;
createdAt: Date;
updatedAt: Date;
diff --git a/apps/dashboard/src/domain/node.ts b/apps/dashboard/src/domain/node.ts
index 7c1f09b..812591e 100644
--- a/apps/dashboard/src/domain/node.ts
+++ b/apps/dashboard/src/domain/node.ts
@@ -10,6 +10,8 @@ export interface NodeOverview {
domain: string;
description: string;
osaVersion: string;
+ /** Newest published release (registry check), null when unknown. */
+ latestOsaVersion: string | null;
status: NodeStatus;
/** Published record count, or null when stats are unavailable. */
records: number | null;
diff --git a/apps/dashboard/src/domain/osa-version.ts b/apps/dashboard/src/domain/osa-version.ts
new file mode 100644
index 0000000..1ebd19b
--- /dev/null
+++ b/apps/dashboard/src/domain/osa-version.ts
@@ -0,0 +1,66 @@
+/**
+ * OSA version registry entries (#222) — what the cloud can provision.
+ *
+ * Registered automatically by the cloud's release poller; `status` is the
+ * support lifecycle. Only `supported` versions are upgrade targets; a
+ * `withdrawn` pin cannot even be redeployed.
+ */
+
+export type OsaVersionStatus = "supported" | "deprecated" | "withdrawn";
+
+export interface OsaVersion {
+ /** Strict `vX.Y.Z` tag, e.g. `v0.0.12`. */
+ version: string;
+ status: OsaVersionStatus;
+ /** The version new archives are born with. */
+ isDefault: boolean;
+ /** Release notes link, stamped by the registry poller. */
+ notesUrl: string | null;
+}
+
+/**
+ * Numeric semver comparison, tolerant of a missing `v` prefix (the tenant
+ * health endpoint reports `0.0.11` while registry tags say `v0.0.11`).
+ * Returns <0 / 0 / >0 like a comparator. Unparseable versions compare as
+ * lowest so they never masquerade as an upgrade.
+ */
+export function compareOsaVersions(a: string, b: string): number {
+ const pa = parseVersion(a);
+ const pb = parseVersion(b);
+ if (!pa && !pb) return 0;
+ if (!pa) return -1;
+ if (!pb) return 1;
+ for (let i = 0; i < 3; i++) {
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
+ if (d !== 0) return d;
+ }
+ return 0;
+}
+
+function parseVersion(v: string): [number, number, number] | null {
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
+ if (!m) return null;
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
+}
+
+/**
+ * Versions the archive may move to: `supported` and strictly newer than the
+ * pin, newest first. Mirrors the cloud's `eligible_upgrade_target()` rule so
+ * the dialog never offers something the API would 400.
+ */
+export function eligibleUpgradeTargets(
+ versions: OsaVersion[],
+ pin: string,
+): OsaVersion[] {
+ return versions
+ .filter(
+ (v) =>
+ v.status === "supported" && compareOsaVersions(v.version, pin) > 0,
+ )
+ .sort((a, b) => compareOsaVersions(b.version, a.version));
+}
+
+/** GitHub compare link between two release tags. */
+export function releaseCompareUrl(from: string, to: string): string {
+ return `https://github.com/opensciencearchive/server/compare/${from}...${to}`;
+}
diff --git a/apps/dashboard/src/features/archive-settings/SettingsPanel.tsx b/apps/dashboard/src/features/archive-settings/SettingsPanel.tsx
index 0c528f7..ddf9ee5 100644
--- a/apps/dashboard/src/features/archive-settings/SettingsPanel.tsx
+++ b/apps/dashboard/src/features/archive-settings/SettingsPanel.tsx
@@ -10,6 +10,8 @@ import { useServices } from "@/api/services";
import { useArchive } from "@/features/archives/useArchives";
import { useSession } from "@/features/auth/useSession";
import { useRedeploy } from "@/features/deployments/useRedeploy";
+import { UpgradeSection } from "@/features/archive-upgrade/UpgradeSection";
+import { SelfHostVersion } from "@/features/archive-upgrade/SelfHostVersion";
import { Button, Card, PageHeader, Skeleton } from "@/ui";
import { blockedReason } from "./blocked";
@@ -65,6 +67,7 @@ function SelfHostSettings() {
from the dashboard.
+
);
}
@@ -86,6 +89,7 @@ function SettingsSections({
}) {
return (
+
diff --git a/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.module.css b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.module.css
new file mode 100644
index 0000000..039b7cb
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.module.css
@@ -0,0 +1,42 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ border-top: 1px solid var(--color-border);
+ padding-top: var(--space-4);
+ margin-top: var(--space-4);
+}
+
+.row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ font-size: var(--text-sm);
+}
+
+.label {
+ color: var(--color-text-subtle);
+}
+
+.hint {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.hintText {
+ font-size: var(--text-sm);
+ color: var(--color-text-subtle);
+ margin: 0;
+}
+
+.command {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ padding: var(--space-2) var(--space-3);
+ background: var(--color-bg);
+ width: fit-content;
+}
diff --git a/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.test.tsx b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.test.tsx
new file mode 100644
index 0000000..b009e90
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.test.tsx
@@ -0,0 +1,69 @@
+import { HttpResponse, http } from "msw";
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { server } from "@/mocks/server";
+import { makeTestServices, renderWithProviders } from "@/test/render";
+
+import { SelfHostVersion } from "./SelfHostVersion";
+
+function nodeOverview(over?: Record
) {
+ return {
+ name: "Pockets",
+ domain: "pockets.example.org",
+ description: "",
+ osaVersion: "0.0.11",
+ status: "ready",
+ records: 1,
+ schemas: 1,
+ latestOsaVersion: "v0.0.12",
+ ...over,
+ };
+}
+
+function selfHost() {
+ const services = makeTestServices();
+ return { ...services, isPlatform: false as const };
+}
+
+describe("SelfHostVersion", () => {
+ it("shows the CLI command when the node is behind the latest release", async () => {
+ server.use(
+ http.get("*/api/node", () => HttpResponse.json(nodeOverview())),
+ );
+ renderWithProviders(, { services: selfHost() });
+
+ expect(
+ await screen.findByText("Update available → v0.0.12"),
+ ).toBeInTheDocument();
+ // The bare `0.0.11` from /health compares correctly against `v0.0.12`.
+ expect(
+ screen.getByText("osa start --osa-version v0.0.12"),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /upgrade/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows up-to-date when current matches latest despite prefix mismatch", async () => {
+ server.use(
+ http.get("*/api/node", () =>
+ HttpResponse.json(nodeOverview({ latestOsaVersion: "v0.0.11" })),
+ ),
+ );
+ renderWithProviders(, { services: selfHost() });
+ expect(await screen.findByText("Up to date")).toBeInTheDocument();
+ });
+
+ it("renders just the version when the registry check failed", async () => {
+ server.use(
+ http.get("*/api/node", () =>
+ HttpResponse.json(nodeOverview({ latestOsaVersion: null })),
+ ),
+ );
+ renderWithProviders(, { services: selfHost() });
+ expect(await screen.findByText("0.0.11")).toBeInTheDocument();
+ expect(screen.queryByText(/Update available/)).not.toBeInTheDocument();
+ expect(screen.queryByText("Up to date")).not.toBeInTheDocument();
+ });
+});
diff --git a/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.tsx b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.tsx
new file mode 100644
index 0000000..ac4ff1e
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { compareOsaVersions } from "@/domain/osa-version";
+import { useNodeOverview } from "@/features/archives/useNodeOverview";
+import { Badge, CopyButton } from "@/ui";
+
+import styles from "./SelfHostVersion.module.css";
+
+/**
+ * Self-host upgrade awareness (#222, user-decided scope): show the node's
+ * version and, when a newer release exists, the exact CLI command — the
+ * dashboard cannot perform the upgrade itself (server and dashboard share
+ * one OSA_IMAGE_VERSION; upgrading the server restarts this dashboard too).
+ */
+export function SelfHostVersion() {
+ const node = useNodeOverview();
+
+ if (!node.data || !node.data.osaVersion) return null;
+
+ const current = node.data.osaVersion;
+ const latest = node.data.latestOsaVersion;
+ const behind =
+ latest !== null && compareOsaVersions(latest, current) > 0;
+
+ return (
+
+
+ OSA version
+ {current}
+ {behind ? (
+
+ Update available → {latest}
+
+ ) : latest !== null ? (
+ Up to date
+ ) : null}
+
+ {behind && (
+
+
+ Run this where the archive is hosted — it restarts the server and
+ this dashboard:
+
+
+ osa start --osa-version {latest}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.module.css b/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.module.css
new file mode 100644
index 0000000..7ad6b21
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.module.css
@@ -0,0 +1,86 @@
+.body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ max-width: 34rem;
+}
+
+.versions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ font-size: var(--text-md);
+}
+
+.notes {
+ max-height: 18rem;
+ overflow-y: auto;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ padding: var(--space-3) var(--space-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.release {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.releaseTitle {
+ font-size: var(--text-sm);
+ font-weight: 600;
+}
+
+.prose {
+ font-size: var(--text-sm);
+ line-height: 1.55;
+}
+
+.prose :global(h1),
+.prose :global(h2),
+.prose :global(h3) {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ margin: var(--space-2) 0 var(--space-1);
+}
+
+.prose :global(ul) {
+ padding-left: var(--space-4);
+ margin: var(--space-1) 0;
+}
+
+.prose :global(code) {
+ font-family: var(--font-mono);
+ font-size: 12.5px;
+ background: var(--color-bg-subtle, rgba(0, 0, 0, 0.04));
+ padding: 0.1em 0.35em;
+ border-radius: 4px;
+}
+
+.notesFallback {
+ font-size: var(--text-sm);
+ color: var(--color-text-subtle);
+ margin: 0;
+}
+
+.warning {
+ font-size: var(--text-sm);
+ color: var(--color-text-subtle);
+ margin: 0;
+}
+
+.error {
+ font-size: var(--text-sm);
+ color: var(--color-danger);
+ margin: 0;
+}
+
+.actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-3);
+ margin-top: var(--space-4);
+}
diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.tsx b/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.tsx
new file mode 100644
index 0000000..5431d82
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.tsx
@@ -0,0 +1,186 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import Markdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+
+import { ApiError } from "@/api/http/errors";
+import type { Archive } from "@/domain/archive";
+import type { OsaVersion } from "@/domain/osa-version";
+import { releaseCompareUrl } from "@/domain/osa-version";
+import { Button, Checkbox, Dialog, Field, Select, Skeleton } from "@/ui";
+
+import { useReleaseNotes } from "./useReleaseNotes";
+import { useUpgradeArchive } from "./useUpgradeArchive";
+import styles from "./UpgradeDialog.module.css";
+
+/**
+ * The deliberate half of #222: from → to, what changed, an explicit
+ * forward-only acknowledgment, then hand off to /deploying/{id}.
+ */
+export function UpgradeDialog({
+ archive,
+ targets,
+ open,
+ onClose,
+}: {
+ archive: Archive;
+ /** Eligible targets, newest first — guaranteed non-empty by the caller. */
+ targets: OsaVersion[];
+ open: boolean;
+ onClose: () => void;
+}) {
+ const newest = targets[0]!;
+ const [toVersion, setToVersion] = useState(newest.version);
+ const [acknowledged, setAcknowledged] = useState(false);
+ const upgrade = useUpgradeArchive(archive.id);
+ const target = targets.find((t) => t.version === toVersion) ?? newest;
+
+ const notes = useReleaseNotes(archive.osaVersionPin, toVersion, open);
+
+ const upgradeReset = upgrade.reset;
+ const close = useCallback(() => {
+ setAcknowledged(false);
+ upgradeReset();
+ onClose();
+ }, [upgradeReset, onClose]);
+
+ const submit = () => {
+ if (!acknowledged || upgrade.isPending) return;
+ upgrade.mutate(toVersion);
+ };
+
+ const errorMessage =
+ upgrade.error instanceof ApiError
+ ? upgrade.error.code === "invalid_state"
+ ? "A deployment is already in progress."
+ : upgrade.error.message
+ : upgrade.error
+ ? "Something went wrong — try again."
+ : undefined;
+
+ return (
+
+ );
+}
+
+function ReleaseNotes({
+ notes,
+ fallbackUrl,
+ compareUrl,
+}: {
+ notes: ReturnType;
+ fallbackUrl: string | null;
+ compareUrl: string;
+}) {
+ if (notes.isPending) {
+ return ;
+ }
+ if (notes.isError || !notes.data) {
+ // GitHub unreachable — degrade to the registry's link-out.
+ return (
+
+ {fallbackUrl ? (
+
+ Read the release notes on GitHub
+
+ ) : (
+ "Release notes are unavailable right now."
+ )}{" "}
+ ·{" "}
+
+ Full comparison
+
+
+ );
+ }
+ return (
+
+ {notes.data.map((release) => (
+
+
+ {release.version}
+ {release.name && release.name !== release.version
+ ? ` — ${release.name}`
+ : null}
+
+
+ {release.body}
+
+
+ ))}
+
+
+ Full comparison on GitHub
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeSection.module.css b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.module.css
new file mode 100644
index 0000000..6aa2109
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.module.css
@@ -0,0 +1,47 @@
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.heading {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.title {
+ font-size: var(--text-md);
+ font-weight: 600;
+}
+
+.chipRow {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ font-size: var(--text-sm);
+}
+
+.failedNote,
+.withdrawnNote {
+ font-size: var(--text-sm);
+ color: var(--color-danger);
+ margin: 0;
+}
+
+.deprecatedNote {
+ font-size: var(--text-sm);
+ color: var(--color-warning, var(--color-text-subtle));
+ margin: 0;
+}
+
+.actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.note {
+ font-size: var(--text-sm);
+ color: var(--color-text-subtle);
+}
diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx
new file mode 100644
index 0000000..f70740e
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx
@@ -0,0 +1,163 @@
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { makeTestServices, renderWithProviders } from "@/test/render";
+import { buildArchive } from "@/test/factories";
+import { mockRouter } from "@/test/router-mock";
+
+import { UpgradeSection } from "./UpgradeSection";
+
+// Seeded mock registry: pin v0.0.9; v0.0.10 + v0.0.11 supported (default),
+// v0.0.8 deprecated. Seeded archives pin v0.0.9.
+
+describe("UpgradeSection", () => {
+ it("shows the pin and an update-available badge when newer versions exist", async () => {
+ const services = makeTestServices();
+ renderWithProviders(
+ ,
+ { services },
+ );
+ expect(await screen.findByText("v0.0.9")).toBeInTheDocument();
+ expect(
+ await screen.findByText("Update available → v0.0.11"),
+ ).toBeInTheDocument();
+ });
+
+ it("shows up-to-date when the pin is the newest supported version", async () => {
+ const services = makeTestServices();
+ renderWithProviders(
+ ,
+ { services },
+ );
+ expect(await screen.findByText("Up to date")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Upgrade…" })).toBeDisabled();
+ });
+
+ it("blocks the button with a reason while a deployment is in flight", async () => {
+ const services = makeTestServices();
+ renderWithProviders(
+ ,
+ { services },
+ );
+ const button = await screen.findByRole("button", { name: "Upgrade…" });
+ await waitFor(() => expect(button).toBeDisabled());
+ expect(
+ screen.getByText("A deployment is already in progress."),
+ ).toBeInTheDocument();
+ });
+
+ it("warns when the pinned version is deprecated", async () => {
+ const services = makeTestServices();
+ renderWithProviders(
+ ,
+ { services },
+ );
+ expect(
+ await screen.findByText(/is deprecated — upgrading is recommended/),
+ ).toBeInTheDocument();
+ });
+
+ it("opens the dialog and performs an upgrade end to end", async () => {
+ const services = makeTestServices();
+ const upgradeSpy = vi.spyOn(services.amacrin, "upgradeArchive");
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ { services },
+ );
+
+ await user.click(await screen.findByRole("button", { name: "Upgrade…" }));
+ const dialog = await screen.findByRole("dialog");
+ expect(dialog).toBeInTheDocument();
+
+ // The confirm button is gated on the forward-only acknowledgment.
+ const confirm = screen.getByRole("button", { name: "Upgrade to v0.0.11" });
+ expect(confirm).toBeDisabled();
+ await user.click(
+ screen.getByLabelText(
+ "I understand upgrades are forward-only and can't be rolled back.",
+ ),
+ );
+ expect(confirm).toBeEnabled();
+
+ await user.click(confirm);
+ await waitFor(() =>
+ expect(upgradeSpy).toHaveBeenCalledWith("arch_sky1mag3ry", "v0.0.11"),
+ );
+ await waitFor(() =>
+ expect(mockRouter.push).toHaveBeenCalledWith(
+ "/deploying/arch_sky1mag3ry",
+ ),
+ );
+ });
+
+ it("lets the user pick an intermediate version when several are eligible", async () => {
+ const services = makeTestServices();
+ const upgradeSpy = vi.spyOn(services.amacrin, "upgradeArchive");
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ { services },
+ );
+
+ await user.click(await screen.findByRole("button", { name: "Upgrade…" }));
+ await user.selectOptions(
+ await screen.findByLabelText("Upgrade to"),
+ "v0.0.10",
+ );
+ await user.click(
+ screen.getByLabelText(
+ "I understand upgrades are forward-only and can't be rolled back.",
+ ),
+ );
+ await user.click(screen.getByRole("button", { name: "Upgrade to v0.0.10" }));
+ await waitFor(() =>
+ expect(upgradeSpy).toHaveBeenCalledWith("arch_sky1mag3ry", "v0.0.10"),
+ );
+ });
+
+ it("surfaces an in-flight conflict as a friendly message", async () => {
+ const services = makeTestServices();
+ // Seeded deploying archive: the mock rejects with invalid_state.
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ { services },
+ );
+
+ await user.click(await screen.findByRole("button", { name: "Upgrade…" }));
+ await user.click(
+ screen.getByLabelText(
+ "I understand upgrades are forward-only and can't be rolled back.",
+ ),
+ );
+ await user.click(screen.getByRole("button", { name: "Upgrade to v0.0.11" }));
+ expect(
+ await screen.findByText("A deployment is already in progress."),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx
new file mode 100644
index 0000000..2912981
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useState } from "react";
+
+import type { Archive } from "@/domain/archive";
+import { isDeployBlocked } from "@/domain/archive";
+import { eligibleUpgradeTargets } from "@/domain/osa-version";
+import { useDeploymentStatus } from "@/features/deployments/useDeploymentStatus";
+import { blockedReason } from "@/features/archive-settings/blocked";
+import { Badge, Button, Card, Skeleton } from "@/ui";
+
+import { useOsaVersions } from "./useOsaVersions";
+import { UpgradeDialog } from "./UpgradeDialog";
+import styles from "./UpgradeSection.module.css";
+
+/**
+ * The archive's OSA version: current pin, availability state, and the
+ * upgrade entry point (#222). Platform mode only — self-host gets a
+ * read-only version line in SelfHostSettings instead.
+ */
+export function UpgradeSection({ archive }: { archive: Archive }) {
+ const versions = useOsaVersions();
+ const latestDeployment = useDeploymentStatus(archive.id);
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ const blocked = isDeployBlocked(archive.status);
+ const reason = blockedReason(archive.status);
+
+ const pin = archive.osaVersionPin;
+ const pinEntry = versions.data?.find((v) => v.version === pin);
+ const targets = versions.data ? eligibleUpgradeTargets(versions.data, pin) : [];
+ const newest = targets[0];
+
+ // The pin moves when an upgrade starts; a failed deployment at the pin
+ // means the upgrade did not land — and a retry deploys the PIN, never the
+ // previous version. Say so instead of implying a rollback.
+ const upgradeFailed =
+ latestDeployment.data?.status.kind === "failed" &&
+ latestDeployment.data.osaVersion !== pin &&
+ !blocked;
+
+ return (
+
+
+
OSA version
+
+ {pin}
+ {versions.isPending ? (
+
+ ) : newest ? (
+
+ Update available → {newest.version}
+
+ ) : (
+ Up to date
+ )}
+
+ {upgradeFailed && (
+
+ The upgrade to {pin} failed during
+ deployment. Retrying deploys {pin} —
+ upgrades don't roll back.
+
+ )}
+ {pinEntry?.status === "deprecated" && (
+
+ {pin} is deprecated — upgrading is
+ recommended.
+
+ )}
+ {pinEntry?.status === "withdrawn" && (
+
+ {pin} has been withdrawn: redeploys
+ of this version are refused. Upgrade required.
+
+ )}
+
+
+
+
+ {blocked && {reason}}
+
+
+ {newest && (
+ setDialogOpen(false)}
+ />
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/features/archive-upgrade/keys.ts b/apps/dashboard/src/features/archive-upgrade/keys.ts
new file mode 100644
index 0000000..cee2675
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/keys.ts
@@ -0,0 +1,5 @@
+export const upgradeKeys = {
+ versions: ["osa-versions"] as const,
+ releaseNotes: (from: string, to: string) =>
+ ["release-notes", from, to] as const,
+};
diff --git a/apps/dashboard/src/features/archive-upgrade/useOsaVersions.ts b/apps/dashboard/src/features/archive-upgrade/useOsaVersions.ts
new file mode 100644
index 0000000..0ea67d1
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/useOsaVersions.ts
@@ -0,0 +1,17 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { usePlatformServices } from "@/api/services";
+
+import { upgradeKeys } from "./keys";
+
+/** GET /osa-versions — the registry, newest first. Platform mode only. */
+export function useOsaVersions() {
+ const { amacrin } = usePlatformServices();
+ return useQuery({
+ queryKey: upgradeKeys.versions,
+ queryFn: () => amacrin.listOsaVersions(),
+ staleTime: 60_000,
+ });
+}
diff --git a/apps/dashboard/src/features/archive-upgrade/useReleaseNotes.ts b/apps/dashboard/src/features/archive-upgrade/useReleaseNotes.ts
new file mode 100644
index 0000000..59512f6
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/useReleaseNotes.ts
@@ -0,0 +1,34 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { upgradeKeys } from "./keys";
+
+export interface ReleaseNote {
+ version: string;
+ name: string;
+ body: string;
+ htmlUrl: string;
+}
+
+/**
+ * Release notes for every version in `(from, to]`, oldest first, via the
+ * dashboard's cached GitHub proxy. Failure is non-fatal — the dialog
+ * degrades to the registry's notes_url link.
+ */
+export function useReleaseNotes(from: string, to: string, enabled: boolean) {
+ return useQuery({
+ queryKey: upgradeKeys.releaseNotes(from, to),
+ enabled,
+ staleTime: 10 * 60_000,
+ retry: false,
+ queryFn: async (): Promise => {
+ const res = await fetch(
+ `/api/releases?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`,
+ );
+ if (!res.ok) throw new Error(`release notes unavailable (${res.status})`);
+ const body = (await res.json()) as { releases: ReleaseNote[] };
+ return body.releases;
+ },
+ });
+}
diff --git a/apps/dashboard/src/features/archive-upgrade/useUpgradeArchive.ts b/apps/dashboard/src/features/archive-upgrade/useUpgradeArchive.ts
new file mode 100644
index 0000000..6763948
--- /dev/null
+++ b/apps/dashboard/src/features/archive-upgrade/useUpgradeArchive.ts
@@ -0,0 +1,31 @@
+"use client";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+
+import { usePlatformServices } from "@/api/services";
+
+import { archiveKeys } from "../archives/keys";
+
+/**
+ * POST /archives/{id}/upgrade — forward-only. On the 202, the deployment is
+ * pre-seeded into the status cache (mirroring useCreateArchive) and the user
+ * is handed to /deploying/{id}, which owns progress from there.
+ */
+export function useUpgradeArchive(archiveId: string) {
+ const { amacrin } = usePlatformServices();
+ const queryClient = useQueryClient();
+ const router = useRouter();
+
+ return useMutation({
+ mutationFn: (toVersion: string) =>
+ amacrin.upgradeArchive(archiveId, toVersion),
+ onSuccess: async (deployment) => {
+ queryClient.setQueryData(archiveKeys.status(archiveId), deployment);
+ await queryClient.invalidateQueries({
+ queryKey: archiveKeys.detail(archiveId),
+ });
+ router.push(`/deploying/${archiveId}`);
+ },
+ });
+}
diff --git a/apps/dashboard/src/features/archives/local-archive.ts b/apps/dashboard/src/features/archives/local-archive.ts
index 63bd019..be7ccbc 100644
--- a/apps/dashboard/src/features/archives/local-archive.ts
+++ b/apps/dashboard/src/features/archives/local-archive.ts
@@ -16,7 +16,8 @@ export function syntheticLocalArchive(): Archive {
slug: SELF_HOST_ARCHIVE_ID,
domain: "",
status: { kind: "running" },
- orcidAdmins: [],
+ orcidAdmins: [],
+ osaVersionPin: "",
deploymentConfig: null,
createdAt: new Date(0),
updatedAt: new Date(0),
diff --git a/apps/dashboard/src/features/deployments/DeployingScreen.test.tsx b/apps/dashboard/src/features/deployments/DeployingScreen.test.tsx
index d71cafa..9311ea4 100644
--- a/apps/dashboard/src/features/deployments/DeployingScreen.test.tsx
+++ b/apps/dashboard/src/features/deployments/DeployingScreen.test.tsx
@@ -18,6 +18,7 @@ const ARCHIVE: Archive = {
domain: "alpine.amacr.in",
status: { kind: "deploying" },
orcidAdmins: [],
+ osaVersionPin: "v0.0.9",
deploymentConfig: null,
createdAt: new Date(0),
updatedAt: new Date(0),
diff --git a/apps/dashboard/src/mocks/fixtures/archive.deploying.json b/apps/dashboard/src/mocks/fixtures/archive.deploying.json
index 2068701..2c1f0bf 100644
--- a/apps/dashboard/src/mocks/fixtures/archive.deploying.json
+++ b/apps/dashboard/src/mocks/fixtures/archive.deploying.json
@@ -10,5 +10,6 @@
},
"status": "deploying",
"created_at": "2026-07-25T13:58:00+00:00",
- "updated_at": "2026-07-25T13:58:00+00:00"
+ "updated_at": "2026-07-25T13:58:00+00:00",
+ "osa_version_pin": "v0.0.9"
}
diff --git a/apps/dashboard/src/mocks/fixtures/archive.error.json b/apps/dashboard/src/mocks/fixtures/archive.error.json
index 43714c1..43abe68 100644
--- a/apps/dashboard/src/mocks/fixtures/archive.error.json
+++ b/apps/dashboard/src/mocks/fixtures/archive.error.json
@@ -7,13 +7,16 @@
"config": {
"auth": {
"admins": {
- "orcid": ["0000-0002-1825-0097"]
+ "orcid": [
+ "0000-0002-1825-0097"
+ ]
}
},
"deployment": null
},
"status": "error",
- "error_message": "provisioning failed: PersistentVolumeClaim pending after 600s — storage class 'gp3' quota exceeded in eu-west-1",
+ "error_message": "provisioning failed: PersistentVolumeClaim pending after 600s \u2014 storage class 'gp3' quota exceeded in eu-west-1",
"created_at": "2026-07-02T08:30:00+00:00",
- "updated_at": "2026-07-24T19:12:44+00:00"
+ "updated_at": "2026-07-24T19:12:44+00:00",
+ "osa_version_pin": "v0.0.9"
}
diff --git a/apps/dashboard/src/mocks/fixtures/archive.running.json b/apps/dashboard/src/mocks/fixtures/archive.running.json
index 10022c9..226b99d 100644
--- a/apps/dashboard/src/mocks/fixtures/archive.running.json
+++ b/apps/dashboard/src/mocks/fixtures/archive.running.json
@@ -7,7 +7,10 @@
"config": {
"auth": {
"admins": {
- "orcid": ["0000-0002-1825-0097", "0000-0001-5109-3700"]
+ "orcid": [
+ "0000-0002-1825-0097",
+ "0000-0001-5109-3700"
+ ]
}
},
"deployment": {
@@ -18,5 +21,7 @@
},
"status": "running",
"created_at": "2026-07-14T10:02:11+00:00",
- "updated_at": "2026-07-25T14:06:00+00:00"
+ "updated_at": "2026-07-25T14:06:00+00:00",
+ "osa_version_pin": "v0.0.9",
+ "osa_version": "v0.0.9"
}
diff --git a/apps/dashboard/src/mocks/fixtures/osa-versions.json b/apps/dashboard/src/mocks/fixtures/osa-versions.json
new file mode 100644
index 0000000..71cb87b
--- /dev/null
+++ b/apps/dashboard/src/mocks/fixtures/osa-versions.json
@@ -0,0 +1,29 @@
+[
+ {
+ "version": "v0.0.11",
+ "status": "supported",
+ "is_default": true,
+ "registered_at": "2026-08-16T12:00:00+00:00",
+ "notes_url": "https://github.com/opensciencearchive/server/releases/tag/v0.0.11"
+ },
+ {
+ "version": "v0.0.10",
+ "status": "supported",
+ "is_default": false,
+ "registered_at": "2026-07-31T21:00:00+00:00",
+ "notes_url": "https://github.com/opensciencearchive/server/releases/tag/v0.0.10"
+ },
+ {
+ "version": "v0.0.9",
+ "status": "supported",
+ "is_default": false,
+ "registered_at": "2026-07-31T17:00:00+00:00",
+ "notes_url": "https://github.com/opensciencearchive/server/releases/tag/v0.0.9"
+ },
+ {
+ "version": "v0.0.8",
+ "status": "deprecated",
+ "is_default": false,
+ "registered_at": "2026-07-20T09:00:00+00:00"
+ }
+]
diff --git a/apps/dashboard/src/mocks/handlers.ts b/apps/dashboard/src/mocks/handlers.ts
index a81c6da..3beddd1 100644
--- a/apps/dashboard/src/mocks/handlers.ts
+++ b/apps/dashboard/src/mocks/handlers.ts
@@ -20,6 +20,7 @@ import deploymentSucceeded from "./fixtures/deployment.succeeded.json";
import me from "./fixtures/me.json";
import members from "./fixtures/members.json";
import organisations from "./fixtures/organisations.json";
+import osaVersions from "./fixtures/osa-versions.json";
const ARCHIVES = [archiveRunning, archiveError, archiveDeploying];
@@ -101,6 +102,7 @@ export const handlers = [
}),
// ── archives ────────────────────────────────────────────────────────
+ http.get("*/api/v1/osa-versions", () => HttpResponse.json(osaVersions)),
http.get("*/api/v1/archives", () => HttpResponse.json(ARCHIVES)),
http.get("*/api/v1/archives/:id", ({ params }) => {
const archive = ARCHIVES.find((a) => a.id === params["id"]);
@@ -108,6 +110,9 @@ export const handlers = [
? HttpResponse.json(archive)
: jsonError(404, "not_found", `archive '${String(params["id"])}' not found`);
}),
+ http.post("*/api/v1/archives/:id/upgrade", () =>
+ HttpResponse.json(deploymentPending, { status: 202 }),
+ ),
http.post("*/api/v1/archives/:id/deploy", ({ params }) =>
HttpResponse.json(
{
diff --git a/apps/dashboard/src/server/ghcr.ts b/apps/dashboard/src/server/ghcr.ts
new file mode 100644
index 0000000..a2c60e9
--- /dev/null
+++ b/apps/dashboard/src/server/ghcr.ts
@@ -0,0 +1,70 @@
+/**
+ * Latest published OSA release, straight from the container registry.
+ *
+ * The registry (not GitHub releases) is the availability signal: a tag in
+ * ghcr is pullable by definition. Same flow as the SDK's
+ * `fetch_latest_osa_version` — anonymous pull-scope token, paginated
+ * tags/list, highest strict `vX.Y.Z` client-side. Cached in-module (~1h);
+ * `null` on any failure so callers degrade instead of breaking.
+ */
+
+const GHCR_IMAGE = "opensciencearchive/osa";
+const CACHE_TTL_MS = 60 * 60 * 1000;
+const MAX_PAGES = 50;
+const RELEASE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/;
+
+let cached: { value: string | null; at: number } | null = null;
+
+export async function latestOsaVersion(): Promise {
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.value;
+ const value = await fetchLatest().catch(() => null);
+ // Cache failures too — a down registry shouldn't be re-probed per render.
+ cached = { value, at: Date.now() };
+ return value;
+}
+
+/** Test hook: drop the module cache. */
+export function resetLatestOsaVersionCache(): void {
+ cached = null;
+}
+
+async function fetchLatest(): Promise {
+ const tokenRes = await fetch(
+ `https://ghcr.io/token?scope=repository:${GHCR_IMAGE}:pull`,
+ );
+ if (!tokenRes.ok) return null;
+ const token = (await tokenRes.json()).token as string | undefined;
+ if (!token) return null;
+
+ const tags: string[] = [];
+ let url: string | null = `https://ghcr.io/v2/${GHCR_IMAGE}/tags/list?n=1000`;
+ for (let page = 0; url !== null && page < MAX_PAGES; page++) {
+ const res: Response = await fetch(url, {
+ headers: { authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) return null;
+ const body = (await res.json()) as { tags?: string[] };
+ tags.push(...(body.tags ?? []));
+ url = nextPageUrl(res.headers.get("link"));
+ }
+
+ const releases = tags
+ .map((t) => ({ tag: t, m: RELEASE_TAG.exec(t) }))
+ .filter((x): x is { tag: string; m: RegExpExecArray } => x.m !== null)
+ .sort(
+ (a, b) =>
+ Number(a.m[1]) - Number(b.m[1]) ||
+ Number(a.m[2]) - Number(b.m[2]) ||
+ Number(a.m[3]) - Number(b.m[3]),
+ );
+ return releases.at(-1)?.tag ?? null;
+}
+
+function nextPageUrl(link: string | null): string | null {
+ if (!link || !link.includes('rel="next"')) return null;
+ const start = link.indexOf("<");
+ const end = link.indexOf(">", start);
+ if (start === -1 || end === -1) return null;
+ const path = link.slice(start + 1, end);
+ return path.startsWith("http") ? path : `https://ghcr.io${path}`;
+}
diff --git a/apps/dashboard/src/server/releases.ts b/apps/dashboard/src/server/releases.ts
new file mode 100644
index 0000000..59e88bc
--- /dev/null
+++ b/apps/dashboard/src/server/releases.ts
@@ -0,0 +1,53 @@
+/**
+ * Cached GitHub release notes for the upgrade dialog (#222).
+ *
+ * Proxied server-side because GitHub's unauthenticated API is rate-limited
+ * per IP — one cached fetch here instead of one per browser.
+ */
+
+const REPO = "opensciencearchive/server";
+const CACHE_TTL_MS = 10 * 60 * 1000;
+
+export const RELEASE_TAG = /^v\d+\.\d+\.\d+$/;
+
+export interface Release {
+ version: string;
+ name: string;
+ body: string;
+ htmlUrl: string;
+}
+
+let cached: { releases: Release[]; at: number } | null = null;
+
+/** Test hook: drop the module cache. */
+export function resetReleasesCache(): void {
+ cached = null;
+}
+
+/** All published (non-draft, non-prerelease) releases. Throws on GitHub failure. */
+export async function publishedReleases(): Promise {
+ if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.releases;
+ const res = await fetch(
+ `https://api.github.com/repos/${REPO}/releases?per_page=50`,
+ { headers: { accept: "application/vnd.github+json" } },
+ );
+ if (!res.ok) throw new Error(`github releases: ${res.status}`);
+ const body = (await res.json()) as {
+ tag_name: string;
+ name: string | null;
+ body: string | null;
+ html_url: string;
+ draft: boolean;
+ prerelease: boolean;
+ }[];
+ const releases = body
+ .filter((r) => !r.draft && !r.prerelease && RELEASE_TAG.test(r.tag_name))
+ .map((r) => ({
+ version: r.tag_name,
+ name: r.name ?? r.tag_name,
+ body: r.body ?? "",
+ htmlUrl: r.html_url,
+ }));
+ cached = { releases, at: Date.now() };
+ return releases;
+}
diff --git a/apps/dashboard/src/test/factories.ts b/apps/dashboard/src/test/factories.ts
index 7c88f6a..684d953 100644
--- a/apps/dashboard/src/test/factories.ts
+++ b/apps/dashboard/src/test/factories.ts
@@ -23,6 +23,7 @@ export function buildArchive(over?: Partial): Archive {
domain: "alpine-climate.amacr.in",
status: { kind: "running" },
orcidAdmins: ["0000-0002-1825-0097"],
+ osaVersionPin: "v0.0.9",
deploymentConfig: { provider: "aws_eks", region: "eu-west-1", volumeSizeGb: 5 },
createdAt: new Date("2026-07-14T10:02:11Z"),
updatedAt: new Date("2026-07-25T14:06:00Z"),
diff --git a/apps/dashboard/src/ui/checkbox/Checkbox.module.css b/apps/dashboard/src/ui/checkbox/Checkbox.module.css
new file mode 100644
index 0000000..b1b5f39
--- /dev/null
+++ b/apps/dashboard/src/ui/checkbox/Checkbox.module.css
@@ -0,0 +1,27 @@
+.wrapper {
+ display: inline-flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+ cursor: pointer;
+ user-select: none;
+}
+
+.box {
+ margin-top: 3px;
+ width: 15px;
+ height: 15px;
+ accent-color: var(--color-accent);
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.box:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+
+.label {
+ font-size: var(--text-sm);
+ color: var(--color-text);
+ line-height: 1.45;
+}
diff --git a/apps/dashboard/src/ui/checkbox/Checkbox.tsx b/apps/dashboard/src/ui/checkbox/Checkbox.tsx
new file mode 100644
index 0000000..e9e831b
--- /dev/null
+++ b/apps/dashboard/src/ui/checkbox/Checkbox.tsx
@@ -0,0 +1,20 @@
+import styles from "./Checkbox.module.css";
+
+export interface CheckboxProps
+ extends Omit, "type"> {
+ /** The clickable label text rendered beside the box. */
+ label: React.ReactNode;
+}
+
+/**
+ * A labelled checkbox — acknowledgment gates ("I understand…"), option
+ * toggles. The whole label is the hit target.
+ */
+export function Checkbox({ label, className, ...rest }: CheckboxProps) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/ui/index.ts b/apps/dashboard/src/ui/index.ts
index d3ade05..da586ca 100644
--- a/apps/dashboard/src/ui/index.ts
+++ b/apps/dashboard/src/ui/index.ts
@@ -2,6 +2,7 @@ export { Badge } from "./badge/Badge";
export { BarChart } from "./bar-chart/BarChart";
export { Button } from "./button/Button";
export { Card, CardFooter } from "./card/Card";
+export { Checkbox } from "./checkbox/Checkbox";
export { CopyButton } from "./copy-button/CopyButton";
export { DataTable, type Column } from "./data-table/DataTable";
export { Dialog } from "./dialog/Dialog";
From 5f50cb7cde2fbf65c9a0b70a415f7e9fdfffb387 Mon Sep 17 00:00:00 2001
From: Rory Byrne
Date: Sun, 16 Aug 2026 17:39:56 +0100
Subject: [PATCH 2/2] fix(dashboard): address Greptile review on the upgrade
flow
- Bound the ghcr latest-version lookup with a 4s AbortSignal deadline so
a slow registry degrades to null instead of holding /api/node (and the
self-host overview skeleton) hostage.
- Distinguish registry errors from up-to-date in UpgradeSection: a failed
versions query now shows 'Version check unavailable' instead of falsely
claiming the pin is newest and suppressing deprecation warnings.
- Paginate GitHub releases via the Link header (shared parser in
server/link-header.ts, also adopted by ghcr.ts) so upgrade ranges
reaching past the newest 50 releases keep their older notes.
4 new regression tests; suite 226 green.
---
.../src/app/api/releases/route.test.ts | 24 ++++++++
.../archive-upgrade/UpgradeSection.test.tsx | 16 ++++++
.../archive-upgrade/UpgradeSection.tsx | 12 +++-
apps/dashboard/src/server/ghcr.test.ts | 28 ++++++++++
apps/dashboard/src/server/ghcr.ts | 20 +++----
apps/dashboard/src/server/link-header.ts | 13 +++++
apps/dashboard/src/server/releases.ts | 56 ++++++++++++-------
7 files changed, 137 insertions(+), 32 deletions(-)
create mode 100644 apps/dashboard/src/server/ghcr.test.ts
create mode 100644 apps/dashboard/src/server/link-header.ts
diff --git a/apps/dashboard/src/app/api/releases/route.test.ts b/apps/dashboard/src/app/api/releases/route.test.ts
index fa9466e..a694f8a 100644
--- a/apps/dashboard/src/app/api/releases/route.test.ts
+++ b/apps/dashboard/src/app/api/releases/route.test.ts
@@ -63,6 +63,30 @@ describe("GET /api/releases", () => {
]);
});
+ it("follows pagination so older releases in the range are not dropped", async () => {
+ const page2 = Response.json([githubRelease("v0.0.10")]);
+ const page1 = new Response(
+ JSON.stringify([githubRelease("v0.0.12"), githubRelease("v0.0.11")]),
+ {
+ headers: {
+ "content-type": "application/json",
+ link: '; rel="next", ; rel="last"',
+ },
+ },
+ );
+ vi.spyOn(globalThis, "fetch")
+ .mockResolvedValueOnce(page1)
+ .mockResolvedValueOnce(page2);
+
+ const res = await GET(await request("from=v0.0.9&to=v0.0.12"));
+ const body = (await res.json()) as { releases: { version: string }[] };
+ expect(body.releases.map((r) => r.version)).toEqual([
+ "v0.0.10",
+ "v0.0.11",
+ "v0.0.12",
+ ]);
+ });
+
it("serves from cache on the second call", async () => {
const spy = vi
.spyOn(globalThis, "fetch")
diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx
index f70740e..0ce69e4 100644
--- a/apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx
+++ b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx
@@ -57,6 +57,22 @@ describe("UpgradeSection", () => {
).toBeInTheDocument();
});
+ it("shows the registry as unavailable, not up to date, when it errors", async () => {
+ const services = makeTestServices();
+ vi.spyOn(services.amacrin, "listOsaVersions").mockRejectedValue(
+ new Error("registry down"),
+ );
+ renderWithProviders(
+ ,
+ { services },
+ );
+ expect(
+ await screen.findByText("Version check unavailable"),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Up to date")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Upgrade…" })).toBeDisabled();
+ });
+
it("warns when the pinned version is deprecated", async () => {
const services = makeTestServices();
renderWithProviders(
diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx
index 2912981..d5bcf1c 100644
--- a/apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx
+++ b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx
@@ -47,6 +47,8 @@ export function UpgradeSection({ archive }: { archive: Archive }) {
{pin}
{versions.isPending ? (
+ ) : versions.isError ? (
+ Version check unavailable
) : newest ? (
Update available → {newest.version}
@@ -80,7 +82,15 @@ export function UpgradeSection({ archive }: { archive: Archive }) {