feat: cache the game-ci CLI download even when cliVersion=latest

cliVersion defaults to 'latest', and caching was previously skipped
entirely for it - only pinned versions (cliVersion: v0.1.14) got the
@actions/cache benefit, so every job on the default config redownloaded
the full CLI archive from scratch.

Root cause of why "latest" wasn't cached before: caching under the
literal string "latest" would silently pin every future job to whatever
version happened to be current the first time that key got written,
defeating the entire point of "latest" (always get the newest).

Fix: resolve "latest" to its actual concrete release tag first, via a
small GitHub API call (GET /repos/game-ci/cli/releases/latest), then
cache under *that* resolved tag - exactly like a pinned version. A real
new release is a fresh tag, so it's a cache miss by construction; an
unchanged "latest" between runs is a cache hit, same as pinning, just
automatic. Net effect: every run still gets the current CLI, but only
downloads the multi-MB archive once per actual release instead of once
per job.

Verification:
- yarn typecheck: clean.
- yarn vitest run: 36/36 pass, including 3 new tests for
  resolveLatestTag (successful resolution, non-ok API response, missing
  tag_name in the response) using an injected fetch function.
- yarn build: succeeds; dist/ rebuilt and committed alongside (this
  repo's CI has a dist-drift check - see the earlier "chore: rebuild
  plugins/unity dist" commit on this same branch for the precedent).
- oxfmt --check: clean.
This commit is contained in:
frostebite
2026-08-24 20:27:53 +01:00
parent ced48ea547
commit 43d4978f4d
4 changed files with 133 additions and 43 deletions
Generated Vendored
+41 -21
View File
@@ -272,7 +272,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
return result; return result;
}; };
Object.defineProperty(exports, "__esModule", ({ value: true })); Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.downloadCli = exports.binaryNameFor = exports.assetNameFor = void 0; exports.downloadCli = exports.resolveLatestTag = exports.binaryNameFor = exports.assetNameFor = void 0;
const fs = __importStar(__nccwpck_require__(3977)); const fs = __importStar(__nccwpck_require__(3977));
const os = __importStar(__nccwpck_require__(612)); const os = __importStar(__nccwpck_require__(612));
const path = __importStar(__nccwpck_require__(9411)); const path = __importStar(__nccwpck_require__(9411));
@@ -298,6 +298,31 @@ function binaryNameFor(platform) {
return platform === 'win32' ? 'game-ci.exe' : 'game-ci'; return platform === 'win32' ? 'game-ci.exe' : 'game-ci';
} }
exports.binaryNameFor = binaryNameFor; exports.binaryNameFor = binaryNameFor;
/**
* Resolves the "latest" alias to the actual release tag it currently
* points to, via a small GitHub API call - not the release-asset
* redirect, which never reveals the concrete tag it landed on. This is
* what makes caching "latest" possible at all: caching under the literal
* string "latest" would silently pin every job to whatever version
* happened to be current on the first cache write, but caching under the
* *resolved* tag self-invalidates the moment a new release ships (a new
* tag is a cache miss by construction), while still hitting cache on
* every run in between.
*/
async function resolveLatestTag(fetchFn = fetch) {
const response = await fetchFn(`https://api.github.com/repos/${CLI_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!response.ok) {
throw new Error(`Failed to resolve the latest game-ci CLI release: GitHub API returned ${response.status}.`);
}
const body = (await response.json());
if (!body.tag_name) {
throw new Error('Failed to resolve the latest game-ci CLI release: response had no tag_name.');
}
return body.tag_name;
}
exports.resolveLatestTag = resolveLatestTag;
/** /**
* Downloads (or reuses a cached copy of) the game-ci CLI release archive * Downloads (or reuses a cached copy of) the game-ci CLI release archive
* matching the current runner, extracts it, and returns the path to the * matching the current runner, extracts it, and returns the path to the
@@ -310,29 +335,26 @@ exports.binaryNameFor = binaryNameFor;
* aren't embedded in the compiled binary itself. dist/ ships as the * aren't embedded in the compiled binary itself. dist/ ships as the
* binary's sibling inside the archive - see game-ci/cli#73. * binary's sibling inside the archive - see game-ci/cli#73.
* *
* Pinned versions are cached via @actions/cache (GitHub's cache service), * Every version - including "latest" - is cached via @actions/cache
* so repeat jobs on ephemeral, GitHub-hosted runners skip the download * (GitHub's cache service), so repeat jobs on ephemeral, GitHub-hosted
* entirely - @actions/tool-cache alone only survives for the life of one * runners skip the archive download entirely - @actions/tool-cache alone
* runner's disk, which GitHub-hosted runners don't persist between jobs. * only survives for the life of one runner's disk, which GitHub-hosted
* "latest" is intentionally never persisted this way: caching a moving * runners don't persist between jobs. "latest" is resolved to its
* target under a fixed key would silently pin every job to whatever * concrete tag first (see resolveLatestTag) and cached under *that*, not
* version happened to be "latest" on the first cache write. * under the literal string "latest" - a real new release is a fresh tag,
* so it's a cache miss by construction, never a stale hit.
* *
* @param version A release tag (e.g. "v0.1.0"), or "latest". * @param version A release tag (e.g. "v0.1.0"), or "latest".
*/ */
async function downloadCli(version) { async function downloadCli(version) {
const asset = assetNameFor(process.platform, process.arch); const asset = assetNameFor(process.platform, process.arch);
const binaryName = binaryNameFor(process.platform); const binaryName = binaryNameFor(process.platform);
const isPinned = version !== 'latest'; const resolvedVersion = version === 'latest' ? await resolveLatestTag() : version;
if (isPinned) { const cached = await restoreFromCache(resolvedVersion, binaryName);
const cached = await restoreFromCache(version, binaryName); if (cached)
if (cached) return cached;
return cached; const url = `https://github.com/${CLI_REPO}/releases/download/${resolvedVersion}/${asset}`;
} core.info(`Downloading game-ci CLI ${resolvedVersion} from ${url}`);
const url = isPinned
? `https://github.com/${CLI_REPO}/releases/download/${version}/${asset}`
: `https://github.com/${CLI_REPO}/releases/latest/download/${asset}`;
core.info(`Downloading game-ci CLI (${version}) from ${url}`);
const archivePath = await tc.downloadTool(url); const archivePath = await tc.downloadTool(url);
const extractedDir = process.platform === 'win32' const extractedDir = process.platform === 'win32'
? await tc.extractZip(archivePath) ? await tc.extractZip(archivePath)
@@ -341,9 +363,7 @@ async function downloadCli(version) {
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
await fs.chmod(binaryPath, 0o755); await fs.chmod(binaryPath, 0o755);
} }
if (isPinned) { await saveToCache(resolvedVersion, binaryName, extractedDir);
await saveToCache(version, binaryName, extractedDir);
}
return binaryPath; return binaryPath;
} }
exports.downloadCli = downloadCli; exports.downloadCli = downloadCli;
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+47 -2
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { assetNameFor, binaryNameFor } from './download-cli'; import { assetNameFor, binaryNameFor, resolveLatestTag } from './download-cli';
describe('assetNameFor', () => { describe('assetNameFor', () => {
it('maps linux x64 to a .tar.gz archive', () => { it('maps linux x64 to a .tar.gz archive', () => {
@@ -38,3 +38,48 @@ describe('binaryNameFor', () => {
expect(binaryNameFor('darwin')).toBe('game-ci'); expect(binaryNameFor('darwin')).toBe('game-ci');
}); });
}); });
describe('resolveLatestTag', () => {
// This is the piece that makes caching "latest" possible at all: caching
// under the literal string "latest" would silently pin every job to
// whatever version happened to be current on the first cache write.
// Resolving to the concrete tag first means a real new release is a fresh
// cache key (a miss by construction), never a stale hit.
it('returns the tag_name from a successful GitHub API response', async () => {
const fetchFn = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ tag_name: 'v0.1.15' }),
})) as unknown as typeof fetch;
const tag = await resolveLatestTag(fetchFn);
expect(tag).toBe('v0.1.15');
expect(fetchFn).toHaveBeenCalledWith(
'https://api.github.com/repos/game-ci/cli/releases/latest',
expect.objectContaining({
headers: expect.objectContaining({ Accept: 'application/vnd.github+json' }),
}),
);
});
it('throws with a clear message on a non-ok response', async () => {
const fetchFn = vi.fn(async () => ({
ok: false,
status: 404,
json: async () => ({}),
})) as unknown as typeof fetch;
await expect(resolveLatestTag(fetchFn)).rejects.toThrow(/404/);
});
it('throws with a clear message when the response has no tag_name', async () => {
const fetchFn = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({}),
})) as unknown as typeof fetch;
await expect(resolveLatestTag(fetchFn)).rejects.toThrow(/no tag_name/);
});
});
+44 -19
View File
@@ -28,6 +28,36 @@ export function binaryNameFor(platform: NodeJS.Platform): string {
return platform === 'win32' ? 'game-ci.exe' : 'game-ci'; return platform === 'win32' ? 'game-ci.exe' : 'game-ci';
} }
/**
* Resolves the "latest" alias to the actual release tag it currently
* points to, via a small GitHub API call - not the release-asset
* redirect, which never reveals the concrete tag it landed on. This is
* what makes caching "latest" possible at all: caching under the literal
* string "latest" would silently pin every job to whatever version
* happened to be current on the first cache write, but caching under the
* *resolved* tag self-invalidates the moment a new release ships (a new
* tag is a cache miss by construction), while still hitting cache on
* every run in between.
*/
export async function resolveLatestTag(fetchFn: typeof fetch = fetch): Promise<string> {
const response = await fetchFn(`https://api.github.com/repos/${CLI_REPO}/releases/latest`, {
headers: { Accept: 'application/vnd.github+json' },
});
if (!response.ok) {
throw new Error(
`Failed to resolve the latest game-ci CLI release: GitHub API returned ${response.status}.`,
);
}
const body = (await response.json()) as { tag_name?: string };
if (!body.tag_name) {
throw new Error('Failed to resolve the latest game-ci CLI release: response had no tag_name.');
}
return body.tag_name;
}
/** /**
* Downloads (or reuses a cached copy of) the game-ci CLI release archive * Downloads (or reuses a cached copy of) the game-ci CLI release archive
* matching the current runner, extracts it, and returns the path to the * matching the current runner, extracts it, and returns the path to the
@@ -40,31 +70,28 @@ export function binaryNameFor(platform: NodeJS.Platform): string {
* aren't embedded in the compiled binary itself. dist/ ships as the * aren't embedded in the compiled binary itself. dist/ ships as the
* binary's sibling inside the archive - see game-ci/cli#73. * binary's sibling inside the archive - see game-ci/cli#73.
* *
* Pinned versions are cached via @actions/cache (GitHub's cache service), * Every version - including "latest" - is cached via @actions/cache
* so repeat jobs on ephemeral, GitHub-hosted runners skip the download * (GitHub's cache service), so repeat jobs on ephemeral, GitHub-hosted
* entirely - @actions/tool-cache alone only survives for the life of one * runners skip the archive download entirely - @actions/tool-cache alone
* runner's disk, which GitHub-hosted runners don't persist between jobs. * only survives for the life of one runner's disk, which GitHub-hosted
* "latest" is intentionally never persisted this way: caching a moving * runners don't persist between jobs. "latest" is resolved to its
* target under a fixed key would silently pin every job to whatever * concrete tag first (see resolveLatestTag) and cached under *that*, not
* version happened to be "latest" on the first cache write. * under the literal string "latest" - a real new release is a fresh tag,
* so it's a cache miss by construction, never a stale hit.
* *
* @param version A release tag (e.g. "v0.1.0"), or "latest". * @param version A release tag (e.g. "v0.1.0"), or "latest".
*/ */
export async function downloadCli(version: string): Promise<string> { export async function downloadCli(version: string): Promise<string> {
const asset = assetNameFor(process.platform, process.arch); const asset = assetNameFor(process.platform, process.arch);
const binaryName = binaryNameFor(process.platform); const binaryName = binaryNameFor(process.platform);
const isPinned = version !== 'latest'; const resolvedVersion = version === 'latest' ? await resolveLatestTag() : version;
if (isPinned) { const cached = await restoreFromCache(resolvedVersion, binaryName);
const cached = await restoreFromCache(version, binaryName); if (cached) return cached;
if (cached) return cached;
}
const url = isPinned const url = `https://github.com/${CLI_REPO}/releases/download/${resolvedVersion}/${asset}`;
? `https://github.com/${CLI_REPO}/releases/download/${version}/${asset}`
: `https://github.com/${CLI_REPO}/releases/latest/download/${asset}`;
core.info(`Downloading game-ci CLI (${version}) from ${url}`); core.info(`Downloading game-ci CLI ${resolvedVersion} from ${url}`);
const archivePath = await tc.downloadTool(url); const archivePath = await tc.downloadTool(url);
const extractedDir = const extractedDir =
process.platform === 'win32' process.platform === 'win32'
@@ -76,9 +103,7 @@ export async function downloadCli(version: string): Promise<string> {
await fs.chmod(binaryPath, 0o755); await fs.chmod(binaryPath, 0o755);
} }
if (isPinned) { await saveToCache(resolvedVersion, binaryName, extractedDir);
await saveToCache(version, binaryName, extractedDir);
}
return binaryPath; return binaryPath;
} }