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
55 changes: 51 additions & 4 deletions src/lib/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { attemptDeltaUpgrade, type DeltaResult } from "./delta-upgrade.js";
import { AbortError, UpgradeError } from "./errors.js";
import {
downloadNightlyBlob,
fetchManifest,
fetchNightlyManifest,
findLayerByFilename,
getAnonymousToken,
Expand Down Expand Up @@ -384,9 +385,41 @@ export function fetchLatestVersion(
: fetchLatestFromNpm();
}

/**
* Check if a versioned nightly tag exists in GHCR.
*
* Nightly builds are published to GHCR with tags like `nightly-0.14.0-dev.1772661724`.
* This performs an anonymous token exchange + manifest fetch (2 HTTP requests).
* Returns false only for 404/403 (tag not found); network errors propagate as
* UpgradeError to match stable version check behavior.
*
* @param version - Nightly version string (e.g., "0.14.0-dev.1772661724")
* @returns true if the nightly tag exists in GHCR, false if not found
* @throws {UpgradeError} On network failure or GHCR unavailability
*/
async function nightlyVersionExists(version: string): Promise<boolean> {
const token = await getAnonymousToken();
try {
await fetchManifest(token, `nightly-${version}`);
return true;
} catch (error) {
// 404 = tag doesn't exist; 403 = token lacks access to non-existent tag
if (
error instanceof UpgradeError &&
(error.message.includes("HTTP 404") || error.message.includes("HTTP 403"))
) {
return false;
}
throw error;
}
}

/**
* Check if a specific version exists in the appropriate registry.
* curl installations check GitHub releases; package managers check npm.
*
* Nightly versions are checked against GHCR (where they are published as
* versioned tags like `nightly-0.14.0-dev.1772661724`). Stable versions
* are checked against GitHub Releases (curl/brew) or npm (package managers).
*
* @param method - How the CLI was installed
* @param version - Version to check (without 'v' prefix)
Expand All @@ -397,6 +430,11 @@ export async function versionExists(
method: InstallationMethod,
version: string
): Promise<boolean> {
// Nightly versions are published to GHCR, not GitHub Releases or npm
if (isNightlyVersion(version)) {
return nightlyVersionExists(version);
}

if (method === "curl" || method === "brew") {
const response = await fetchWithUpgradeError(
`${GITHUB_RELEASES_URL}/tags/${version}`,
Expand Down Expand Up @@ -470,12 +508,21 @@ function getNightlyGzFilename(): string {
* matching this platform's `.gz` filename, then downloads and decompresses
* the blob in-stream.
*
* When `version` is provided, fetches the pinned versioned tag
* (`nightly-{version}`). Otherwise fetches the rolling `:nightly` tag.
*
* @param destPath - File path to write the decompressed binary
* @param version - Specific nightly version to download (omit for latest)
* @throws {UpgradeError} When GHCR fetch or blob download fails
*/
async function downloadNightlyToPath(destPath: string): Promise<void> {
async function downloadNightlyToPath(
destPath: string,
version?: string
): Promise<void> {
const token = await getAnonymousToken();
const manifest = await fetchNightlyManifest(token);
const manifest = version
? await fetchManifest(token, `nightly-${version}`)
: await fetchNightlyManifest(token);
const filename = getNightlyGzFilename();
const layer = findLayerByFilename(manifest, filename);
const response = await downloadNightlyBlob(token, layer.digest);
Expand Down Expand Up @@ -635,7 +682,7 @@ async function downloadFullBinary(
destPath: string
): Promise<void> {
if (isNightlyVersion(version)) {
await downloadNightlyToPath(destPath);
await downloadNightlyToPath(destPath, version);
} else {
await downloadStableToPath(downloadTag ?? version, destPath);
}
Expand Down
73 changes: 73 additions & 0 deletions test/lib/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,79 @@ describe("versionExists", () => {
"Failed to connect to npm registry"
);
});

test("checks GHCR for nightly version - version exists", async () => {
const manifest = { schemaVersion: 2, layers: [], annotations: {} };
mockFetch(async (url) => {
const u = String(url);
if (u.includes("ghcr.io/token")) {
return new Response(JSON.stringify({ token: "tok" }), { status: 200 });
}
if (u.includes("/manifests/nightly-")) {
return new Response(JSON.stringify(manifest), { status: 200 });
}
return new Response(null, { status: 404 });
});

const exists = await versionExists("curl", "0.14.0-dev.1772661724");
expect(exists).toBe(true);
});

test("checks GHCR for nightly version - version does not exist", async () => {
mockFetch(async (url) => {
const u = String(url);
if (u.includes("ghcr.io/token")) {
return new Response(JSON.stringify({ token: "tok" }), { status: 200 });
}
if (u.includes("/manifests/nightly-")) {
return new Response(null, { status: 404 });
}
return new Response(null, { status: 404 });
});

const exists = await versionExists("curl", "0.14.0-dev.9999999999");
expect(exists).toBe(false);
});

test("checks GHCR for nightly version regardless of install method", async () => {
const manifest = { schemaVersion: 2, layers: [], annotations: {} };
mockFetch(async (url) => {
const u = String(url);
if (u.includes("ghcr.io/token")) {
return new Response(JSON.stringify({ token: "tok" }), { status: 200 });
}
if (u.includes("/manifests/nightly-")) {
return new Response(JSON.stringify(manifest), { status: 200 });
}
return new Response(null, { status: 404 });
});

const exists = await versionExists("npm", "0.14.0-dev.1772661724");
expect(exists).toBe(true);
});

test("throws on network failure for nightly version", async () => {
mockFetch(async () => {
throw new TypeError("fetch failed");
});
await expect(
versionExists("curl", "0.14.0-dev.1772661724")
).rejects.toThrow(UpgradeError);
});

test("throws on GHCR server error for nightly version", async () => {
mockFetch(async (url) => {
const u = String(url);
if (u.includes("ghcr.io/token")) {
return new Response(JSON.stringify({ token: "tok" }), { status: 200 });
}
// Manifest returns 500 (server error, not 404)
return new Response(null, { status: 500 });
});
await expect(
versionExists("curl", "0.14.0-dev.1772661724")
).rejects.toThrow(UpgradeError);
});
});

describe("executeUpgrade", () => {
Expand Down