Save the cache state along with the binary
This commit is contained in:
parent
ecb6dc0bd6
commit
00e5cc93c9
197
src/action.ts
197
src/action.ts
@ -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) {
|
||||||
|
|||||||
@ -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.");
|
||||||
|
|||||||
@ -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);
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user