mirror of
https://github.com/game-ci/unity-builder.git
synced 2026-09-29 12:07:05 -07:00
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:
+41
-21
@@ -272,7 +272,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
||||
return result;
|
||||
};
|
||||
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 os = __importStar(__nccwpck_require__(612));
|
||||
const path = __importStar(__nccwpck_require__(9411));
|
||||
@@ -298,6 +298,31 @@ function binaryNameFor(platform) {
|
||||
return platform === 'win32' ? 'game-ci.exe' : 'game-ci';
|
||||
}
|
||||
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
|
||||
* 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
|
||||
* binary's sibling inside the archive - see game-ci/cli#73.
|
||||
*
|
||||
* Pinned versions are cached via @actions/cache (GitHub's cache service),
|
||||
* so repeat jobs on ephemeral, GitHub-hosted runners skip the download
|
||||
* entirely - @actions/tool-cache alone only survives for the life of one
|
||||
* runner's disk, which GitHub-hosted runners don't persist between jobs.
|
||||
* "latest" is intentionally never persisted this way: caching a moving
|
||||
* target under a fixed key would silently pin every job to whatever
|
||||
* version happened to be "latest" on the first cache write.
|
||||
* Every version - including "latest" - is cached via @actions/cache
|
||||
* (GitHub's cache service), so repeat jobs on ephemeral, GitHub-hosted
|
||||
* runners skip the archive download entirely - @actions/tool-cache alone
|
||||
* only survives for the life of one runner's disk, which GitHub-hosted
|
||||
* runners don't persist between jobs. "latest" is resolved to its
|
||||
* concrete tag first (see resolveLatestTag) and cached under *that*, not
|
||||
* 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".
|
||||
*/
|
||||
async function downloadCli(version) {
|
||||
const asset = assetNameFor(process.platform, process.arch);
|
||||
const binaryName = binaryNameFor(process.platform);
|
||||
const isPinned = version !== 'latest';
|
||||
if (isPinned) {
|
||||
const cached = await restoreFromCache(version, binaryName);
|
||||
if (cached)
|
||||
return cached;
|
||||
}
|
||||
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 resolvedVersion = version === 'latest' ? await resolveLatestTag() : version;
|
||||
const cached = await restoreFromCache(resolvedVersion, binaryName);
|
||||
if (cached)
|
||||
return cached;
|
||||
const url = `https://github.com/${CLI_REPO}/releases/download/${resolvedVersion}/${asset}`;
|
||||
core.info(`Downloading game-ci CLI ${resolvedVersion} from ${url}`);
|
||||
const archivePath = await tc.downloadTool(url);
|
||||
const extractedDir = process.platform === 'win32'
|
||||
? await tc.extractZip(archivePath)
|
||||
@@ -341,9 +363,7 @@ async function downloadCli(version) {
|
||||
if (process.platform !== 'win32') {
|
||||
await fs.chmod(binaryPath, 0o755);
|
||||
}
|
||||
if (isPinned) {
|
||||
await saveToCache(version, binaryName, extractedDir);
|
||||
}
|
||||
await saveToCache(resolvedVersion, binaryName, extractedDir);
|
||||
return binaryPath;
|
||||
}
|
||||
exports.downloadCli = downloadCli;
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { assetNameFor, binaryNameFor } from './download-cli';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { assetNameFor, binaryNameFor, resolveLatestTag } from './download-cli';
|
||||
|
||||
describe('assetNameFor', () => {
|
||||
it('maps linux x64 to a .tar.gz archive', () => {
|
||||
@@ -38,3 +38,48 @@ describe('binaryNameFor', () => {
|
||||
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
@@ -28,6 +28,36 @@ export function binaryNameFor(platform: NodeJS.Platform): string {
|
||||
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
|
||||
* 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
|
||||
* binary's sibling inside the archive - see game-ci/cli#73.
|
||||
*
|
||||
* Pinned versions are cached via @actions/cache (GitHub's cache service),
|
||||
* so repeat jobs on ephemeral, GitHub-hosted runners skip the download
|
||||
* entirely - @actions/tool-cache alone only survives for the life of one
|
||||
* runner's disk, which GitHub-hosted runners don't persist between jobs.
|
||||
* "latest" is intentionally never persisted this way: caching a moving
|
||||
* target under a fixed key would silently pin every job to whatever
|
||||
* version happened to be "latest" on the first cache write.
|
||||
* Every version - including "latest" - is cached via @actions/cache
|
||||
* (GitHub's cache service), so repeat jobs on ephemeral, GitHub-hosted
|
||||
* runners skip the archive download entirely - @actions/tool-cache alone
|
||||
* only survives for the life of one runner's disk, which GitHub-hosted
|
||||
* runners don't persist between jobs. "latest" is resolved to its
|
||||
* concrete tag first (see resolveLatestTag) and cached under *that*, not
|
||||
* 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".
|
||||
*/
|
||||
export async function downloadCli(version: string): Promise<string> {
|
||||
const asset = assetNameFor(process.platform, process.arch);
|
||||
const binaryName = binaryNameFor(process.platform);
|
||||
const isPinned = version !== 'latest';
|
||||
const resolvedVersion = version === 'latest' ? await resolveLatestTag() : version;
|
||||
|
||||
if (isPinned) {
|
||||
const cached = await restoreFromCache(version, binaryName);
|
||||
if (cached) return cached;
|
||||
}
|
||||
const cached = await restoreFromCache(resolvedVersion, binaryName);
|
||||
if (cached) return cached;
|
||||
|
||||
const url = isPinned
|
||||
? `https://github.com/${CLI_REPO}/releases/download/${version}/${asset}`
|
||||
: `https://github.com/${CLI_REPO}/releases/latest/download/${asset}`;
|
||||
const url = `https://github.com/${CLI_REPO}/releases/download/${resolvedVersion}/${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 extractedDir =
|
||||
process.platform === 'win32'
|
||||
@@ -76,9 +103,7 @@ export async function downloadCli(version: string): Promise<string> {
|
||||
await fs.chmod(binaryPath, 0o755);
|
||||
}
|
||||
|
||||
if (isPinned) {
|
||||
await saveToCache(version, binaryName, extractedDir);
|
||||
}
|
||||
await saveToCache(resolvedVersion, binaryName, extractedDir);
|
||||
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user