Adds OS name and version (e.g., ubuntu-22.04, macos-14, windows-2022) to the cache key to prevent binary incompatibility issues when GitHub runner images change. Changes: - Add getOSNameVersion() to detect OS name and version - Include OS version in cache key (bump CACHE_VERSION to 2) - Add cache-key output for debugging and testing - Add test-cache-key-os-version workflow job - Update test-musl to verify alpine in cache key - Document cache key components in docs/caching.md Closes #703
9.4 KiB
Plan: OS-Version Specific Cache Keys
Issue
GitHub Issue #703: Users need OS-version specific cache keys to avoid binary incompatibility when GitHub runner images change.
Problem: The current cache key uses process.platform which produces generic identifiers like unknown-linux-gnu. This causes issues when:
- Workflows run on different runner OS versions (e.g.,
ubuntu-20.04vsubuntu-22.04) - GitHub updates the runner image
- Cached binary artifacts (compiled native extensions) are incompatible with different
glibcversions
Solution: Include OS name and version in cache keys (e.g., ubuntu-22.04, macos-14, windows-2022).
Implementation
1. Add OS Version Detection (src/utils/platforms.ts)
Add a new getOSNameVersion() function that detects the OS name and version:
Linux:
- Read
/etc/os-release(fallback to/usr/lib/os-release) - Parse
IDfield (e.g.,ubuntu,debian,fedora,alpine) - Parse
VERSION_IDfield (e.g.,22.04,24.04) - Return:
ubuntu-22.04,alpine-3.18, etc.
macOS:
- Use
os.release()to get Darwin kernel version - Convert to macOS version:
macOS version = Darwin major version - 9 - Return:
macos-14,macos-15, etc.
Windows:
- Use
os.version()which returns e.g.,Windows Server 2022 Datacenter - Parse with regex to extract version number
- Return:
windows-2022,windows-2025, etc.
Error Handling: If OS detection fails, the action will fail with a clear error message.
import fs from "node:fs";
import os from "node:os";
export function getOSNameVersion(): string {
const platform = process.platform;
if (platform === "linux") {
return getLinuxOSNameVersion();
} else if (platform === "darwin") {
return getMacOSNameVersion();
} else if (platform === "win32") {
return getWindowsNameVersion();
}
throw new Error(`Unsupported platform: ${platform}`);
}
function getLinuxOSNameVersion(): string {
const files = ["/etc/os-release", "/usr/lib/os-release"];
for (const file of files) {
try {
const content = fs.readFileSync(file, "utf8");
const id = parseOsReleaseValue(content, "ID");
const versionId = parseOsReleaseValue(content, "VERSION_ID");
if (id && versionId) {
return `${id}-${versionId}`;
}
} catch {
// Try next file
}
}
throw new Error(
"Failed to determine Linux distribution. " +
"Could not read /etc/os-release or /usr/lib/os-release"
);
}
function parseOsReleaseValue(content: string, key: string): string | undefined {
const regex = new RegExp(`^${key}=["']?([^"'\\n]*)["']?$`, "m");
const match = content.match(regex);
return match?.[1];
}
function getMacOSNameVersion(): string {
const darwinVersion = parseInt(os.release().split(".")[0], 10);
if (isNaN(darwinVersion)) {
throw new Error(`Failed to parse macOS version from: ${os.release()}`);
}
const macosVersion = darwinVersion - 9;
return `macos-${macosVersion}`;
}
function getWindowsNameVersion(): string {
const version = os.version();
const match = version.match(/Windows(?: Server)? (\d+)/);
if (!match) {
throw new Error(`Failed to parse Windows version from: ${version}`);
}
return `windows-${match[1]}`;
}
2. Update Cache Key Generation (src/cache/restore-cache.ts)
Bump cache version from "1" to "2" to invalidate old caches.
Update computeKeys() to include OS version:
import { getArch, getOSNameVersion, getPlatform } from "../utils/platforms";
const CACHE_VERSION = "2";
async function computeKeys(): Promise<string> {
// ... existing code for cacheDependencyPathHash ...
const suffix = cacheSuffix ? `-${cacheSuffix}` : "";
const pythonVersion = await getPythonVersion();
const platform = await getPlatform();
const osNameVersion = getOSNameVersion(); // NEW
const pruned = pruneCache ? "-pruned" : "";
const python = cachePython ? "-py" : "";
return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${osNameVersion}-${pythonVersion}${pruned}${python}${cacheDependencyPathHash}${suffix}`;
}
Add cache-key output in restoreCache():
export async function restoreCache(): Promise<void> {
const cacheKey = await computeKeys();
core.saveState(STATE_CACHE_KEY, cacheKey);
core.setOutput("cache-key", cacheKey); // NEW
// ... rest of function
}
3. Add Output Definition (action.yml)
Add new output:
outputs:
uv-version:
description: "The installed uv version. Useful when using latest."
uv-path:
description: "The path to the installed uv binary."
uvx-path:
description: "The path to the installed uvx binary."
cache-hit:
description: "A boolean value to indicate a cache entry was found"
cache-key:
description: "The cache key used for storing/restoring the cache"
venv:
description: "Path to the activated venv if activate-environment is true"
4. Add Workflow Tests (.github/workflows/test.yml)
Add new test job to verify cache keys contain expected OS versions:
test-cache-key-os-version:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-22.04
expected-os: "ubuntu-22.04"
- os: ubuntu-24.04
expected-os: "ubuntu-24.04"
- os: macos-13
expected-os: "macos-13"
- os: macos-14
expected-os: "macos-14"
- os: macos-15
expected-os: "macos-15"
- os: windows-2022
expected-os: "windows-2022"
- os: windows-2025
expected-os: "windows-2025"
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup uv
id: setup-uv
uses: ./
with:
enable-cache: true
- name: Verify cache key contains OS version
run: |
echo "Cache key: $CACHE_KEY"
if [[ "$CACHE_KEY" != *"${{ matrix.expected-os }}"* ]]; then
echo "Cache key does not contain expected OS version: ${{ matrix.expected-os }}"
exit 1
fi
shell: bash
env:
CACHE_KEY: ${{ steps.setup-uv.outputs.cache-key }}
Update existing test-musl job to also verify cache key:
test-musl:
runs-on: ubuntu-latest
container: alpine
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Install latest version
id: setup-uv
uses: ./
with:
enable-cache: true
- name: Verify cache key contains alpine
run: |
echo "Cache key: $CACHE_KEY"
if echo "$CACHE_KEY" | grep -qv "alpine"; then
echo "Cache key does not contain 'alpine'"
exit 1
fi
shell: sh
env:
CACHE_KEY: ${{ steps.setup-uv.outputs.cache-key }}
- run: uv sync
working-directory: __tests__/fixtures/uv-project
Add test-cache-key-os-version to all-tests-passed needs list.
5. Update Documentation (docs/caching.md)
Add section explaining the new cache key behavior:
## Cache key components
The cache key is automatically generated based on:
- **Architecture**: CPU architecture (e.g., `x86_64`, `aarch64`)
- **Platform**: OS platform type (e.g., `unknown-linux-gnu`, `unknown-linux-musl`, `apple-darwin`, `pc-windows-msvc`)
- **OS version**: OS name and version (e.g., `ubuntu-22.04`, `macos-14`, `windows-2022`)
- **Python version**: The Python version in use
- **Cache options**: Whether pruning and Python caching are enabled
- **Dependency hash**: Hash of files matching `cache-dependency-glob`
- **Suffix**: Optional `cache-suffix` if provided
This ensures that caches are not shared between different OS versions, preventing binary incompatibility issues when runner images change.
The computed cache key is available as the `cache-key` output:
\`\`\`yaml
- name: Setup uv
id: setup-uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Print cache key
run: echo "Cache key: ${{ steps.setup-uv.outputs.cache-key }}"
\`\`\`
Cache Key Format
Before (v1):
setup-uv-1-x86_64-unknown-linux-gnu-3.11.0-pruned-abc123
After (v2):
setup-uv-2-x86_64-unknown-linux-gnu-ubuntu-22.04-3.11.0-pruned-abc123
The existing platform component (unknown-linux-gnu) is kept because it distinguishes glibc vs musl libc, which is still important.
Files to Modify
| File | Changes |
|---|---|
src/utils/platforms.ts |
Add getOSNameVersion(), getLinuxOSNameVersion(), getMacOSNameVersion(), getWindowsNameVersion(), parseOsReleaseValue() |
src/cache/restore-cache.ts |
Import getOSNameVersion; bump CACHE_VERSION to "2"; add OS version to cache key; add cache-key output |
action.yml |
Add cache-key output definition |
.github/workflows/test.yml |
Add test-cache-key-os-version job; update test-musl to verify cache key; update all-tests-passed needs |
docs/caching.md |
Document cache key components and new cache-key output |
Backwards Compatibility
- Bumping
CACHE_VERSIONto"2"ensures old caches are not reused - Users will experience a one-time cache miss after upgrading
- This is intentional to ensure all caches have OS-version awareness
Error Handling
If OS detection fails, the action fails with a clear error message. This is intentional because:
- Using an incompatible cache is worse than no cache
- If
/etc/os-releasedoesn't exist, we're on an unusual system that may have other issues - Clear errors help users understand what's happening