Save the cache state along with the binary

This commit is contained in:
tcely 2026-03-14 19:50:14 -04:00
parent ecb6dc0bd6
commit 00e5cc93c9
No known key found for this signature in database
GPG Key ID: 9F1DAF9D9070430F
4 changed files with 191 additions and 55 deletions

View File

@ -1,6 +1,6 @@
import { mkdirSync, symlinkSync, existsSync, readFileSync } from "node:fs"; import { mkdirSync, symlinkSync, existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { dirname, join } from "node:path";
import { cwd } from "node:process"; import { cwd } from "node:process";
import { isFeatureAvailable, restoreCache } from "@actions/cache"; import { isFeatureAvailable, restoreCache } from "@actions/cache";
import { addPath, saveState, info, warning } from "@actions/core"; import { addPath, saveState, info, warning } from "@actions/core";
@ -8,13 +8,16 @@ import { atomicWriteFileSync } from "./atomic-write";
import { writeBunfig } from "./bunfig"; import { writeBunfig } from "./bunfig";
import { downloadBun } from "./download-bun"; import { downloadBun } from "./download-bun";
import { getDownloadUrl } from "./download-url"; import { getDownloadUrl } from "./download-url";
import { quickFingerprint } from "./quick-checksum";
import { Registry } from "./registry"; import { Registry } from "./registry";
import { isGitHub } from "./url";
import { import {
exe, exe,
extractVersionFromUrl, extractVersionFromUrl,
getCacheKey, getCacheKey,
getRevision, getRevision,
isVersionMatch, isVersionMatch,
stripUrlCredentials,
} from "./utils"; } from "./utils";
export type Input = { export type Input = {
@ -34,6 +37,7 @@ export type Output = {
revision: string; revision: string;
bunPath: string; bunPath: string;
url: string; url: string;
checksum?: string;
cacheHit: boolean; cacheHit: boolean;
}; };
@ -42,54 +46,147 @@ export type CacheState = {
cacheHit: boolean; cacheHit: boolean;
bunPath: string; bunPath: string;
url: string; url: string;
checksum?: string;
binaryFingerprint?: string;
revision?: string;
}; };
export default async (options: Input): Promise<Output> => { export default async (options: Input): Promise<Output> => {
const bunfigPath = join(cwd(), "bunfig.toml"); const bunfigPath = join(cwd(), "bunfig.toml");
writeBunfig(bunfigPath, options.registries); writeBunfig(bunfigPath, options.registries);
const url = await getDownloadUrl(options);
const cacheEnabled = isCacheEnabled(options); const cacheEnabled = isCacheEnabled(options);
const url = await getDownloadUrl(options);
const sUrl = isGitHub(url) ? url : stripUrlCredentials(url);
const binPath = join(homedir(), ".bun", "bin"); const binPath = join(homedir(), ".bun", "bin");
try { try {
mkdirSync(binPath, { recursive: true }); mkdirSync(binPath, { recursive: true });
} catch (error) { } catch (error) {
if (error.code !== "EEXIST") { if ("EEXIST" !== error.code) {
throw error; throw error;
} }
} }
addPath(binPath);
const bunPath = join(binPath, exe("bun")); const bunPath = join(binPath, exe("bun"));
try { try {
symlinkSync(bunPath, join(binPath, exe("bunx"))); symlinkSync(bunPath, join(binPath, exe("bunx")));
} catch (error) { } catch (error) {
if (error.code !== "EEXIST") { if ("EEXIST" !== error.code) {
throw error; throw error;
} }
} }
let checksum: string | undefined;
let revision: string | undefined; let revision: string | undefined;
let cacheHit = false; let cacheHit = false;
// Check if Bun executable already exists and matches requested version const cacheState: CacheState = {
if (!options.customUrl && existsSync(bunPath)) { cacheEnabled,
const existingRevision = await getRevision(bunPath); cacheHit,
if (existingRevision && isVersionMatch(existingRevision, options.version)) { bunPath,
revision = existingRevision; url: sUrl,
cacheHit = true; // Treat as cache hit to avoid unnecessary network requests checksum,
info(`Using existing Bun installation: ${revision}`); revision,
};
const cacheKey = getCacheKey(url);
const statePath = join(homedir(), ".bun", "bun.json");
if (cacheEnabled) {
if (existsSync(statePath)) {
try {
const state = JSON.parse(readFileSync(statePath, "utf8")) as CacheState;
if (state.url !== sUrl) {
throw new Error("The URL did not match.");
}
if ("string" === typeof state.checksum) {
checksum = state.checksum;
}
if ("string" === typeof state.binaryFingerprint) {
cacheState.binaryFingerprint = state.binaryFingerprint;
}
if ("string" === typeof state.bunPath) {
cacheState.bunPath = state.bunPath;
}
if ("string" === typeof state.revision) {
cacheState.revision = state.revision;
}
} catch {
warning(`Ignoring metadata from: ${statePath}`);
}
if (checksum) {
cacheState.cacheHit = true;
cacheState.checksum = checksum;
}
} }
} }
// Check if Bun executable already exists and matches requested version
if (
!options.customUrl &&
cacheState.revision &&
existsSync(cacheState.bunPath)
) {
if (isVersionMatch(cacheState.revision, options.version)) {
if (cacheState.binaryFingerprint) {
try {
const livePrint = quickFingerprint(cacheState.bunPath);
if (livePrint === cacheState.binaryFingerprint) {
revision = cacheState.revision;
// Treat as cache hit to avoid unnecessary network requests
cacheHit = cacheState.cacheHit;
info(`Using existing Bun installation: ${revision}`);
} else {
info(
`Binary at ${cacheState.bunPath} does not match stored fingerprint; re-downloading.`,
);
}
} catch {
info(
`Could not verify binary at ${cacheState.bunPath}; re-downloading.`,
);
}
}
// No fingerprint in sidecar (first run after adding this feature):
// fall through to re-download which will compute and persist it.
}
}
const cachePaths = [cacheState.bunPath, statePath];
if (!revision) { if (!revision) {
if (cacheEnabled) { if (cacheEnabled) {
const cacheKey = getCacheKey(url); const cacheRestored = await restoreCache(cachePaths, cacheKey);
const cacheRestored = await restoreCache([bunPath], cacheKey);
if (cacheRestored) { if (cacheRestored) {
revision = await getRevision(bunPath); if (existsSync(statePath)) {
try {
const state = JSON.parse(
readFileSync(statePath, "utf8"),
) as CacheState;
if (state.url !== sUrl) {
throw new Error("The URL did not match.");
}
if ("string" === typeof state.checksum) {
checksum = state.checksum;
cacheState.checksum = checksum;
}
if ("string" === typeof state.binaryFingerprint) {
// There was a fingerprint, but restoring always invalidates it.
cacheState.binaryFingerprint = "restored";
}
if ("string" === typeof state.bunPath) {
cacheState.bunPath = state.bunPath;
}
if ("string" === typeof state.revision) {
revision = state.revision;
cacheState.revision = revision;
}
} catch {
warning(`Ignoring cached metadata from: ${statePath}`);
}
}
if (revision) { if (revision) {
const expectedVersion = extractVersionFromUrl(url); const expectedVersion = extractVersionFromUrl(url);
const [actualVersion] = revision.split("+"); const [actualVersion] = revision.split("+");
@ -103,28 +200,50 @@ export default async (options: Input): Promise<Output> => {
`Cached Bun version ${revision} does not match expected version ${expectedVersion}. Re-downloading.`, `Cached Bun version ${revision} does not match expected version ${expectedVersion}. Re-downloading.`,
); );
revision = undefined; revision = undefined;
} else { } else if (cacheState.checksum) {
cacheHit = true; cacheHit = true;
cacheState.cacheHit = cacheHit;
// Refresh fingerprint so the local fast-path works on the next run
try {
if ("restored" === cacheState.binaryFingerprint) {
cacheState.binaryFingerprint = quickFingerprint(
cacheState.bunPath,
);
atomicWriteFileSync(statePath, JSON.stringify(cacheState));
}
} catch {
// non-critical; next run will just fall back to restoreCache
}
info(`Using a cached version of Bun: ${revision}`); info(`Using a cached version of Bun: ${revision}`);
} }
} else { } else {
warning( warning(
`Found a cached version of Bun: ${revision} (but it appears to be corrupted?)`, `Found a Bun binary (with an unknown version) at: ${cacheState.bunPath}`,
); );
} }
} }
} }
}
if (!cacheHit) { if (!cacheHit) {
info(`Downloading a new version of Bun: ${url}`); cacheState.cacheHit = false;
const result = await downloadBun(url, bunPath, options.token);
cacheState.cacheHit = false;
checksum = result.checksum;
cacheState.checksum = checksum;
revision = await getRevision(bunPath); info(`Downloading a new version of Bun: ${url}`);
cacheState.revision = revision; const result = await downloadBun(url, bunPath, options.token);
checksum = result.checksum;
cacheState.bunPath = result.binPath;
cacheState.checksum = checksum;
cacheState.url = result.url;
try {
cacheState.binaryFingerprint = quickFingerprint(result.binPath);
} catch {
warning(`Could not fingerprint: ${result.binPath}`);
} }
revision = await getRevision(result.binPath);
cacheState.revision = revision;
} }
if (!revision) { if (!revision) {
@ -135,20 +254,25 @@ export default async (options: Input): Promise<Output> => {
const [version] = revision.split("+"); const [version] = revision.split("+");
const cacheState: CacheState = { cacheState.cacheHit = cacheHit;
cacheEnabled, cacheState.checksum = checksum;
cacheHit, cacheState.revision = revision;
bunPath, const stateValue = JSON.stringify({
url, ...cacheState,
}; url: stripUrlCredentials(cacheState.url),
});
saveState("cache", JSON.stringify(cacheState)); if (cacheEnabled && !cacheHit) {
atomicWriteFileSync(statePath, stateValue);
}
saveState("cache", stateValue);
addPath(dirname(cacheState.bunPath));
return { return {
version, version,
revision, revision,
bunPath, bunPath: cacheState.bunPath,
url, url: cacheState.url,
checksum,
cacheHit, cacheHit,
}; };
}; };
@ -156,6 +280,7 @@ export default async (options: Input): Promise<Output> => {
function isCacheEnabled(options: Input): boolean { function isCacheEnabled(options: Input): boolean {
const { customUrl, version, noCache } = options; const { customUrl, version, noCache } = options;
if (noCache) { if (noCache) {
process.env["FS_CACHE_FORCE_STALE"] = "1";
return false; return false;
} }
if (customUrl) { if (customUrl) {

View File

@ -1,14 +1,24 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { saveCache } from "@actions/cache"; import { saveCache } from "@actions/cache";
import { getState, warning } from "@actions/core"; import { getState, warning } from "@actions/core";
import { CacheState } from "./action"; import { CacheState } from "./action";
import { getCacheKey } from "./utils"; import { getCacheKey } from "./utils";
(async () => { (async () => {
const state: CacheState = JSON.parse(getState("cache")); const rawState = getState("cache");
if (!rawState) {
process.exit(0);
}
const state: CacheState = JSON.parse(rawState);
if (state.cacheEnabled && !state.cacheHit) { if (state.cacheEnabled && !state.cacheHit) {
const bunPath = state.bunPath;
const statePath = join(homedir(), ".bun", "bun.json");
const cacheKey = getCacheKey(state.url); const cacheKey = getCacheKey(state.url);
const cachePaths = [bunPath, statePath];
try { try {
await saveCache([state.bunPath], cacheKey); await saveCache(cachePaths, cacheKey);
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
warning("Failed to save Bun to cache."); warning("Failed to save Bun to cache.");

View File

@ -33,11 +33,12 @@ runAction({
noCache: getBooleanInput("no-cache") || false, noCache: getBooleanInput("no-cache") || false,
token: getInput("token"), token: getInput("token"),
}) })
.then(({ version, revision, bunPath, url, cacheHit }) => { .then(({ version, revision, bunPath, url, checksum, cacheHit }) => {
setOutput("bun-version", version); setOutput("bun-version", version);
setOutput("bun-revision", revision); setOutput("bun-revision", revision);
setOutput("bun-path", bunPath); setOutput("bun-path", bunPath);
setOutput("bun-download-url", url); setOutput("bun-download-url", url);
setOutput("bun-download-checksum", checksum ?? "");
setOutput("cache-hit", cacheHit); setOutput("cache-hit", cacheHit);
process.exit(0); process.exit(0);
}) })

View File

@ -50,7 +50,7 @@ export async function verifyAsset(
downloadUrl: string, downloadUrl: string,
token?: string, token?: string,
algorithm: SupportedAlgorithmNames = "sha256", algorithm: SupportedAlgorithmNames = "sha256",
): Promise<void> { ): Promise<string> {
const manifestFile = getManifest(algorithm); const manifestFile = getManifest(algorithm);
const urlObj = new URL(downloadUrl); const urlObj = new URL(downloadUrl);
@ -80,9 +80,18 @@ export async function verifyAsset(
* for custom/mirror URLs where parseAssetUrl() cannot resolve metadata. * for custom/mirror URLs where parseAssetUrl() cannot resolve metadata.
* Real security mismatches are always re-thrown. * Real security mismatches are always re-thrown.
*/ */
let manifestBaseUrl = ""; let metadata: Awaited<ReturnType<typeof fetchAssetMetadata>> | undefined;
try { try {
const metadata = await fetchAssetMetadata(downloadUrl, token); metadata = await fetchAssetMetadata(downloadUrl, token);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
warning(
`Skipping GitHub API digest check for: ${downloadUrl} (${message})`,
);
}
let manifestBaseUrl = "";
if (metadata) {
assetName = metadata.name; assetName = metadata.name;
manifestBaseUrl = getGitHubManifestUrl( manifestBaseUrl = getGitHubManifestUrl(
metadata.owner, metadata.owner,
@ -90,8 +99,7 @@ export async function verifyAsset(
metadata.tag, metadata.tag,
manifestFile, manifestFile,
); );
const updatedAt = new Date(metadata.updated_at); if (Number.isNaN(metadata.updated_at.getTime())) {
if (Number.isNaN(updatedAt.getTime())) {
silentUnlink(zipPath); silentUnlink(zipPath);
throw new DigestVerificationError( throw new DigestVerificationError(
`Invalid updated_at for asset ${assetName}`, `Invalid updated_at for asset ${assetName}`,
@ -103,7 +111,7 @@ export async function verifyAsset(
* For assets updated after our threshold, we cross-reference our local hash * For assets updated after our threshold, we cross-reference our local hash
* with GitHub's infrastructure hash. * with GitHub's infrastructure hash.
*/ */
if (updatedAt >= GITHUB_DIGEST_THRESHOLD) { if (metadata.updated_at >= GITHUB_DIGEST_THRESHOLD) {
info(`Verifying via asset metadata: ${assetName}`); info(`Verifying via asset metadata: ${assetName}`);
if (metadata.digest) { if (metadata.digest) {
const githubHash = getHexFromDigest(metadata.digest); const githubHash = getHexFromDigest(metadata.digest);
@ -117,18 +125,10 @@ export async function verifyAsset(
setOutput("bun-download-checksum", `${metadata.digest}`); setOutput("bun-download-checksum", `${metadata.digest}`);
} else { } else {
warning( warning(
`GitHub digest missing for asset updated on ${updatedAt.toISOString()}`, `GitHub digest missing for asset updated on ${metadata.updated_at.toISOString()}`,
); );
} }
} }
} catch (err) {
if (err instanceof DigestVerificationError) {
throw err; // always propagate real mismatches
}
const message = err instanceof Error ? err.message : String(err);
warning(
`Skipping GitHub API digest check for: ${downloadUrl} (${message})`,
);
} }
/** /**
@ -198,7 +198,7 @@ export async function verifyAsset(
} }
info(`Successfully verified ${assetName} (PGP + ${manifestFile})`); info(`Successfully verified ${assetName} (PGP + ${manifestFile})`);
setOutput("bun-download-checksum", `${algorithm}:${manifestHash}`); return `${algorithm}:${manifestHash}`;
} }
function silentUnlink(filePath: string): void { function silentUnlink(filePath: string): void {