From 042e9c5c9b596dba7513f0e0b3baa945dc92ff55 Mon Sep 17 00:00:00 2001 From: Eric Dill Date: Thu, 7 May 2026 10:36:50 -0400 Subject: [PATCH] fix: reuse existing pixi if version matches on re-download When this action runs more than once in the same job with the same `pixi-version`, the second invocation fails with: Error: Destination file path /home/runner/.pixi/bin/pixi already exists This is because `determinePixiInstallation` takes the "download anyway" branch whenever `pixi-version` or `pixi-url` is set (ignoring any preinstalled pixi), and `@actions/tool-cache`'s `downloadTool` errors on an existing destination file. Before downloading, check whether the destination binary already exists. If it does and `--version` matches the requested version, skip the download. Otherwise remove the stale binary so the download can proceed. The `latest` case always replaces (since we can't know whether it matches without a network call). Closes #107. Co-Authored-By: Claude Opus 4.7 --- dist/index.js | 39 +++++++++++++++++++++++++++++++++++++ src/main.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/dist/index.js b/dist/index.js index 0d40d32..6b4bf0f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -71269,6 +71269,29 @@ var activateEnvironment = async (environment) => { }; // src/main.ts +var fileExists = async (p) => { + try { + await import_promises2.default.access(p); + return true; + } catch { + return false; + } +}; +var readInstalledPixiVersion = async (binPath) => { + try { + const { stdout, exitCode } = await executeGetOutput([binPath, "--version"], { + silent: true, + ignoreReturnCode: true + }); + if (exitCode !== 0) { + return void 0; + } + const match2 = /\b(\d+\.\d+\.\d+\S*)/.exec(stdout); + return match2?.[1]; + } catch { + return void 0; + } +}; var downloadPixi = async (source) => { const url2 = renderPixiUrl(source.urlTemplate, source.version); await group("Downloading Pixi", async () => { @@ -71276,6 +71299,22 @@ var downloadPixi = async (source) => { debug(`Downloading pixi from ${url2}`); debug(`Using headers: ${JSON.stringify(source.headers)}`); await import_promises2.default.mkdir(import_path3.default.dirname(options.pixiBinPath), { recursive: true }); + if (await fileExists(options.pixiBinPath)) { + if (source.version !== "latest") { + const installed = await readInstalledPixiVersion(options.pixiBinPath); + const requested = source.version.replace(/^v/, ""); + if (installed && installed === requested) { + info(`Pixi ${installed} already installed at ${options.pixiBinPath}, skipping download`); + return; + } + info( + `Replacing existing pixi at ${options.pixiBinPath} (installed: ${installed ?? "unknown"}, requested: ${requested})` + ); + } else { + info(`Replacing existing pixi at ${options.pixiBinPath} (requested: latest)`); + } + await import_promises2.default.rm(options.pixiBinPath, { force: true }); + } await downloadTool(url2, options.pixiBinPath, void 0, source.headers); await import_promises2.default.chmod(options.pixiBinPath, 493); info(`Pixi installed to ${options.pixiBinPath}`); diff --git a/src/main.ts b/src/main.ts index 07bc87a..9e56480 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,10 +6,38 @@ import * as core from '@actions/core' import { downloadTool } from '@actions/tool-cache' import type { PixiSource } from './options' import { options } from './options' -import { execute, pixiCmd, renderPixiUrl } from './util' +import { execute, executeGetOutput, pixiCmd, renderPixiUrl } from './util' import { tryRestoreGlobalCache, tryRestoreProjectCache, saveGlobalCache, saveProjectCache } from './cache' import { activateEnvironment } from './activate' +const fileExists = async (p: string) => { + try { + await fs.access(p) + return true + } catch { + return false + } +} + +// Returns the installed version (e.g. "0.67.2") or undefined if the binary +// cannot be executed or the output does not match the expected format. +const readInstalledPixiVersion = async (binPath: string) => { + try { + const { stdout, exitCode } = await executeGetOutput([binPath, '--version'], { + silent: true, + ignoreReturnCode: true + }) + if (exitCode !== 0) { + return undefined + } + // `pixi --version` prints something like "pixi 0.67.2" + const match = /\b(\d+\.\d+\.\d+\S*)/.exec(stdout) + return match?.[1] + } catch { + return undefined + } +} + const downloadPixi = async (source: PixiSource) => { const url = renderPixiUrl(source.urlTemplate, source.version) await core.group('Downloading Pixi', async () => { @@ -17,6 +45,29 @@ const downloadPixi = async (source: PixiSource) => { core.debug(`Downloading pixi from ${url}`) core.debug(`Using headers: ${JSON.stringify(source.headers)}`) await fs.mkdir(path.dirname(options.pixiBinPath), { recursive: true }) + + // If a previous step in this job already installed pixi at the same path, + // @actions/tool-cache's downloadTool will throw "Destination file path + // ... already exists". If the existing binary matches the requested + // version we can safely skip the download; otherwise remove it so the + // download can proceed. See prefix-dev/setup-pixi#107. + if (await fileExists(options.pixiBinPath)) { + if (source.version !== 'latest') { + const installed = await readInstalledPixiVersion(options.pixiBinPath) + const requested = source.version.replace(/^v/, '') + if (installed && installed === requested) { + core.info(`Pixi ${installed} already installed at ${options.pixiBinPath}, skipping download`) + return + } + core.info( + `Replacing existing pixi at ${options.pixiBinPath} (installed: ${installed ?? 'unknown'}, requested: ${requested})` + ) + } else { + core.info(`Replacing existing pixi at ${options.pixiBinPath} (requested: latest)`) + } + await fs.rm(options.pixiBinPath, { force: true }) + } + await downloadTool(url, options.pixiBinPath, undefined, source.headers) await fs.chmod(options.pixiBinPath, 0o755) core.info(`Pixi installed to ${options.pixiBinPath}`)