From 58b6d7b303576a48786c1bbb6959b7875a3a84ec Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sat, 13 Dec 2025 17:25:42 +0100 Subject: [PATCH] fix: add OS version to cache key to prevent binary incompatibility (#716) ## Summary - Adds OS name and version (e.g., `ubuntu-22.04`, `macos-14`, `windows-2022`) to cache keys to prevent binary incompatibility when GitHub updates runner images - Fixes issue where cached uv binaries compiled against older glibc/library versions fail on newer runner OS versions ## Changes - Added `getOSNameVersion()` function to `src/utils/platforms.ts` with OS-specific detection for Linux (via `/etc/os-release`), macOS (Darwin kernel version mapping), and Windows - Updated cache key format to include OS version, bumped `CACHE_VERSION` to `"2"` - Added `cache-key` output to expose the generated cache key for debugging - Added `test-cache-key-os-version` job testing across multiple OS versions - Updated `docs/caching.md` with cache key documentation Closes #703 --- .github/workflows/test.yml | 51 +++++++++++++++++++++++++ action.yml | 2 + dist/save-cache/index.js | 77 +++++++++++++++++++++++++++++++++++++- dist/setup/index.js | 77 +++++++++++++++++++++++++++++++++++++- docs/caching.md | 28 ++++++++++++++ src/cache/restore-cache.ts | 8 ++-- src/utils/platforms.ts | 70 ++++++++++++++++++++++++++++++++++ 7 files changed, 306 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38265ca..966901a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -385,10 +385,60 @@ jobs: 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 + 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-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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + 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 }} + test-setup-cache: runs-on: ${{ matrix.os }} strategy: @@ -1002,6 +1052,7 @@ jobs: - test-python-version - test-activate-environment - test-musl + - test-cache-key-os-version - test-cache-local - test-cache-local-cache-disabled - test-cache-local-cache-disabled-but-explicit-path diff --git a/action.yml b/action.yml index eb7e637..f5fe6ed 100644 --- a/action.yml +++ b/action.yml @@ -89,6 +89,8 @@ outputs: 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" runs: diff --git a/dist/save-cache/index.js b/dist/save-cache/index.js index 7ca7636..a5b4991 100644 --- a/dist/save-cache/index.js +++ b/dist/save-cache/index.js @@ -90609,10 +90609,11 @@ const inputs_1 = __nccwpck_require__(9612); const platforms_1 = __nccwpck_require__(8361); exports.STATE_CACHE_KEY = "cache-key"; exports.STATE_CACHE_MATCHED_KEY = "cache-matched-key"; -const CACHE_VERSION = "1"; +const CACHE_VERSION = "2"; async function restoreCache() { const cacheKey = await computeKeys(); core.saveState(exports.STATE_CACHE_KEY, cacheKey); + core.setOutput("cache-key", cacheKey); if (!inputs_1.restoreCache) { core.info("restore-cache is false. Skipping restore cache step."); return; @@ -90652,9 +90653,10 @@ async function computeKeys() { const suffix = inputs_1.cacheSuffix ? `-${inputs_1.cacheSuffix}` : ""; const pythonVersion = await getPythonVersion(); const platform = await (0, platforms_1.getPlatform)(); + const osNameVersion = (0, platforms_1.getOSNameVersion)(); const pruned = inputs_1.pruneCache ? "-pruned" : ""; const python = inputs_1.cachePython ? "-py" : ""; - return `setup-uv-${CACHE_VERSION}-${(0, platforms_1.getArch)()}-${platform}-${pythonVersion}${pruned}${python}${cacheDependencyPathHash}${suffix}`; + return `setup-uv-${CACHE_VERSION}-${(0, platforms_1.getArch)()}-${platform}-${osNameVersion}-${pythonVersion}${pruned}${python}${cacheDependencyPathHash}${suffix}`; } async function getPythonVersion() { if (inputs_1.pythonVersion !== "") { @@ -91282,9 +91284,15 @@ var __importStar = (this && this.__importStar) || (function () { return result; }; })(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.getArch = getArch; exports.getPlatform = getPlatform; +exports.getOSNameVersion = getOSNameVersion; +const node_fs_1 = __importDefault(__nccwpck_require__(3024)); +const node_os_1 = __importDefault(__nccwpck_require__(8161)); const core = __importStar(__nccwpck_require__(7484)); const exec = __importStar(__nccwpck_require__(5236)); function getArch() { @@ -91342,6 +91350,63 @@ async function isMuslOs() { return false; } } +/** + * Returns OS name and version for cache key differentiation. + * Examples: "ubuntu-22.04", "macos-14", "windows-2022" + * Throws if OS detection fails. + */ +function getOSNameVersion() { + const platform = process.platform; + if (platform === "linux") { + return getLinuxOSNameVersion(); + } + if (platform === "darwin") { + return getMacOSNameVersion(); + } + if (platform === "win32") { + return getWindowsNameVersion(); + } + throw new Error(`Unsupported platform: ${platform}`); +} +function getLinuxOSNameVersion() { + const files = ["/etc/os-release", "/usr/lib/os-release"]; + for (const file of files) { + try { + const content = node_fs_1.default.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, key) { + const regex = new RegExp(`^${key}=["']?([^"'\\n]*)["']?$`, "m"); + const match = content.match(regex); + return match?.[1]; +} +function getMacOSNameVersion() { + const darwinVersion = Number.parseInt(node_os_1.default.release().split(".")[0], 10); + if (Number.isNaN(darwinVersion)) { + throw new Error(`Failed to parse macOS version from: ${node_os_1.default.release()}`); + } + const macosVersion = darwinVersion - 9; + return `macos-${macosVersion}`; +} +function getWindowsNameVersion() { + const version = node_os_1.default.version(); + const match = version.match(/Windows(?: Server)? (\d+)/); + if (!match) { + throw new Error(`Failed to parse Windows version from: ${version}`); + } + return `windows-${match[1]}`; +} /***/ }), @@ -91482,6 +91547,14 @@ module.exports = require("node:fs"); /***/ }), +/***/ 8161: +/***/ ((module) => { + +"use strict"; +module.exports = require("node:os"); + +/***/ }), + /***/ 6760: /***/ ((module) => { diff --git a/dist/setup/index.js b/dist/setup/index.js index 686df29..9bae0d3 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -91512,10 +91512,11 @@ const inputs_1 = __nccwpck_require__(9612); const platforms_1 = __nccwpck_require__(8361); exports.STATE_CACHE_KEY = "cache-key"; exports.STATE_CACHE_MATCHED_KEY = "cache-matched-key"; -const CACHE_VERSION = "1"; +const CACHE_VERSION = "2"; async function restoreCache() { const cacheKey = await computeKeys(); core.saveState(exports.STATE_CACHE_KEY, cacheKey); + core.setOutput("cache-key", cacheKey); if (!inputs_1.restoreCache) { core.info("restore-cache is false. Skipping restore cache step."); return; @@ -91555,9 +91556,10 @@ async function computeKeys() { const suffix = inputs_1.cacheSuffix ? `-${inputs_1.cacheSuffix}` : ""; const pythonVersion = await getPythonVersion(); const platform = await (0, platforms_1.getPlatform)(); + const osNameVersion = (0, platforms_1.getOSNameVersion)(); const pruned = inputs_1.pruneCache ? "-pruned" : ""; const python = inputs_1.cachePython ? "-py" : ""; - return `setup-uv-${CACHE_VERSION}-${(0, platforms_1.getArch)()}-${platform}-${pythonVersion}${pruned}${python}${cacheDependencyPathHash}${suffix}`; + return `setup-uv-${CACHE_VERSION}-${(0, platforms_1.getArch)()}-${platform}-${osNameVersion}-${pythonVersion}${pruned}${python}${cacheDependencyPathHash}${suffix}`; } async function getPythonVersion() { if (inputs_1.pythonVersion !== "") { @@ -96791,9 +96793,15 @@ var __importStar = (this && this.__importStar) || (function () { return result; }; })(); +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.getArch = getArch; exports.getPlatform = getPlatform; +exports.getOSNameVersion = getOSNameVersion; +const node_fs_1 = __importDefault(__nccwpck_require__(3024)); +const node_os_1 = __importDefault(__nccwpck_require__(8161)); const core = __importStar(__nccwpck_require__(7484)); const exec = __importStar(__nccwpck_require__(5236)); function getArch() { @@ -96851,6 +96859,63 @@ async function isMuslOs() { return false; } } +/** + * Returns OS name and version for cache key differentiation. + * Examples: "ubuntu-22.04", "macos-14", "windows-2022" + * Throws if OS detection fails. + */ +function getOSNameVersion() { + const platform = process.platform; + if (platform === "linux") { + return getLinuxOSNameVersion(); + } + if (platform === "darwin") { + return getMacOSNameVersion(); + } + if (platform === "win32") { + return getWindowsNameVersion(); + } + throw new Error(`Unsupported platform: ${platform}`); +} +function getLinuxOSNameVersion() { + const files = ["/etc/os-release", "/usr/lib/os-release"]; + for (const file of files) { + try { + const content = node_fs_1.default.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, key) { + const regex = new RegExp(`^${key}=["']?([^"'\\n]*)["']?$`, "m"); + const match = content.match(regex); + return match?.[1]; +} +function getMacOSNameVersion() { + const darwinVersion = Number.parseInt(node_os_1.default.release().split(".")[0], 10); + if (Number.isNaN(darwinVersion)) { + throw new Error(`Failed to parse macOS version from: ${node_os_1.default.release()}`); + } + const macosVersion = darwinVersion - 9; + return `macos-${macosVersion}`; +} +function getWindowsNameVersion() { + const version = node_os_1.default.version(); + const match = version.match(/Windows(?: Server)? (\d+)/); + if (!match) { + throw new Error(`Failed to parse Windows version from: ${version}`); + } + return `windows-${match[1]}`; +} /***/ }), @@ -97216,6 +97281,14 @@ module.exports = require("node:fs"); /***/ }), +/***/ 8161: +/***/ ((module) => { + +"use strict"; +module.exports = require("node:os"); + +/***/ }), + /***/ 6760: /***/ ((module) => { diff --git a/docs/caching.md b/docs/caching.md index e613831..64350ad 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -2,6 +2,34 @@ This document covers all caching-related configuration options for setup-uv. +## Cache key + +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 + +Including the OS version 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 }}" +``` + ## Enable caching > [!NOTE] diff --git a/src/cache/restore-cache.ts b/src/cache/restore-cache.ts index a850f96..cdf4e27 100644 --- a/src/cache/restore-cache.ts +++ b/src/cache/restore-cache.ts @@ -13,15 +13,16 @@ import { restoreCache as shouldRestoreCache, workingDirectory, } from "../utils/inputs"; -import { getArch, getPlatform } from "../utils/platforms"; +import { getArch, getOSNameVersion, getPlatform } from "../utils/platforms"; export const STATE_CACHE_KEY = "cache-key"; export const STATE_CACHE_MATCHED_KEY = "cache-matched-key"; -const CACHE_VERSION = "1"; +const CACHE_VERSION = "2"; export async function restoreCache(): Promise { const cacheKey = await computeKeys(); core.saveState(STATE_CACHE_KEY, cacheKey); + core.setOutput("cache-key", cacheKey); if (!shouldRestoreCache) { core.info("restore-cache is false. Skipping restore cache step."); @@ -72,9 +73,10 @@ async function computeKeys(): Promise { const suffix = cacheSuffix ? `-${cacheSuffix}` : ""; const pythonVersion = await getPythonVersion(); const platform = await getPlatform(); + const osNameVersion = getOSNameVersion(); const pruned = pruneCache ? "-pruned" : ""; const python = cachePython ? "-py" : ""; - return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${pythonVersion}${pruned}${python}${cacheDependencyPathHash}${suffix}`; + return `setup-uv-${CACHE_VERSION}-${getArch()}-${platform}-${osNameVersion}-${pythonVersion}${pruned}${python}${cacheDependencyPathHash}${suffix}`; } async function getPythonVersion(): Promise { diff --git a/src/utils/platforms.ts b/src/utils/platforms.ts index ff60955..14ce436 100644 --- a/src/utils/platforms.ts +++ b/src/utils/platforms.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; +import os from "node:os"; import * as core from "@actions/core"; import * as exec from "@actions/exec"; export type Platform = @@ -74,3 +76,71 @@ async function isMuslOs(): Promise { return false; } } + +/** + * Returns OS name and version for cache key differentiation. + * Examples: "ubuntu-22.04", "macos-14", "windows-2022" + * Throws if OS detection fails. + */ +export function getOSNameVersion(): string { + const platform = process.platform; + + if (platform === "linux") { + return getLinuxOSNameVersion(); + } + if (platform === "darwin") { + return getMacOSNameVersion(); + } + 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 = Number.parseInt(os.release().split(".")[0], 10); + if (Number.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]}`; +}