From 156786c425f4b4169717def2a3b322fd7e1ab675 Mon Sep 17 00:00:00 2001 From: Bruno Verachten Date: Wed, 18 Mar 2026 15:48:13 +0100 Subject: [PATCH 1/3] feat: fall back to system Python when pre-built binaries unavailable When pre-built Python binaries are not available for the current architecture (e.g., riscv64), try using system Python as a fallback instead of failing with an error. This enables setup-python to work on architectures that don't yet have pre-built binaries in actions/python-versions, such as riscv64 self-hosted runners. The fallback only activates when: 1. No cached version is found 2. No manifest entry exists for the architecture 3. System python3 exists and satisfies the requested version spec A warning is emitted so users know the fallback was used. Fixes #1288 Signed-off-by: Bruno Verachten --- dist/setup/index.js | 24 ++++++++++++++++++++++++ src/find-python.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/dist/setup/index.js b/dist/setup/index.js index 41363d8b..b53c01a8 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -82997,6 +82997,30 @@ async function useCpythonVersion(version, architecture, updateEnvironment, check installDir = tc.find('Python', semanticVersionSpec, architecture); } } + if (!installDir) { + // Try system Python as fallback (e.g., on architectures without pre-built binaries) + try { + const { exitCode, stdout } = await exec.getExecOutput('python3', [ + '-c', + 'import sys; print(sys.prefix)' + ]); + if (exitCode === 0) { + const systemPrefix = stdout.trim(); + const systemVersion = await exec.getExecOutput('python3', [ + '-c', + 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")' + ]); + if (systemVersion.exitCode === 0 && + semver.satisfies(systemVersion.stdout.trim(), semanticVersionSpec)) { + installDir = systemPrefix; + core.warning(`Pre-built Python not available for architecture '${architecture}'. Using system Python ${systemVersion.stdout.trim()} at ${systemPrefix}.`); + } + } + } + catch { + // System Python not available, fall through to error + } + } if (!installDir) { const osInfo = await (0, utils_1.getOSInfo)(); const msg = [ diff --git a/src/find-python.ts b/src/find-python.ts index 99c6a7f2..9e8de0da 100644 --- a/src/find-python.ts +++ b/src/find-python.ts @@ -122,6 +122,34 @@ export async function useCpythonVersion( } } + if (!installDir) { + // Try system Python as fallback (e.g., on architectures without pre-built binaries) + try { + const {exitCode, stdout} = await exec.getExecOutput('python3', [ + '-c', + 'import sys; print(sys.prefix)' + ]); + if (exitCode === 0) { + const systemPrefix = stdout.trim(); + const systemVersion = await exec.getExecOutput('python3', [ + '-c', + 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")' + ]); + if ( + systemVersion.exitCode === 0 && + semver.satisfies(systemVersion.stdout.trim(), semanticVersionSpec) + ) { + installDir = systemPrefix; + core.warning( + `Pre-built Python not available for architecture '${architecture}'. Using system Python ${systemVersion.stdout.trim()} at ${systemPrefix}.` + ); + } + } + } catch { + // System Python not available, fall through to error + } + } + if (!installDir) { const osInfo = await getOSInfo(); const msg = [ From 02cd31ab133b6137685500c6f3bb561a4135fd49 Mon Sep 17 00:00:00 2001 From: Bruno Verachten Date: Thu, 19 Mar 2026 14:31:03 +0100 Subject: [PATCH 2/3] fix: address review feedback on system Python fallback - Gate fallback on architecture having no manifest entries, preventing false triggers on supported architectures with missing versions - Skip fallback for free-threaded Python builds - Return early with correct outputs (sys.executable, version, bin dir) instead of relying on toolcache path parsing which breaks with system prefix paths like /usr - Set environment variables (pythonLocation, Python_ROOT_DIR, etc.) correctly for system Python paths Signed-off-by: Bruno Verachten --- dist/setup/index.js | 51 ++++++++++++++++++++++------------ src/find-python.ts | 68 +++++++++++++++++++++++++++++++-------------- 2 files changed, 80 insertions(+), 39 deletions(-) diff --git a/dist/setup/index.js b/dist/setup/index.js index b53c01a8..1a27fe9d 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -82997,28 +82997,43 @@ async function useCpythonVersion(version, architecture, updateEnvironment, check installDir = tc.find('Python', semanticVersionSpec, architecture); } } - if (!installDir) { - // Try system Python as fallback (e.g., on architectures without pre-built binaries) - try { - const { exitCode, stdout } = await exec.getExecOutput('python3', [ - '-c', - 'import sys; print(sys.prefix)' - ]); - if (exitCode === 0) { - const systemPrefix = stdout.trim(); - const systemVersion = await exec.getExecOutput('python3', [ + if (!installDir && !freethreaded) { + // Try system Python as fallback, but only for architectures that have + // no pre-built binaries in the manifest at all. This prevents the + // fallback from firing when a specific version is missing on a + // supported architecture (e.g., requesting Python 3.99 on x86_64). + const baseArchitecture = architecture.replace('-freethreaded', ''); + if (!manifest) { + manifest = await installer.getManifest(); + } + const archHasManifestEntries = manifest?.some(release => release.files?.some((file) => file.arch === baseArchitecture)); + if (!archHasManifestEntries) { + try { + const sysInfo = await exec.getExecOutput('python3', [ '-c', - 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")' + 'import sys, os; print(sys.executable + "\\n" + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + "\\n" + sys.prefix + "\\n" + os.path.dirname(sys.executable))' ]); - if (systemVersion.exitCode === 0 && - semver.satisfies(systemVersion.stdout.trim(), semanticVersionSpec)) { - installDir = systemPrefix; - core.warning(`Pre-built Python not available for architecture '${architecture}'. Using system Python ${systemVersion.stdout.trim()} at ${systemPrefix}.`); + if (sysInfo.exitCode === 0) { + const [sysExecutable, sysVersion, sysPrefix, sysBinDir] = sysInfo.stdout.trim().split('\n'); + if (semver.satisfies(sysVersion, semanticVersionSpec)) { + core.warning(`Pre-built Python not available for architecture '${baseArchitecture}'. Using system Python ${sysVersion} at ${sysExecutable}.`); + if (updateEnvironment) { + core.exportVariable('pythonLocation', sysPrefix); + core.exportVariable('PKG_CONFIG_PATH', sysPrefix + '/lib/pkgconfig'); + core.exportVariable('Python_ROOT_DIR', sysPrefix); + core.exportVariable('Python2_ROOT_DIR', sysPrefix); + core.exportVariable('Python3_ROOT_DIR', sysPrefix); + core.addPath(sysBinDir); + } + core.setOutput('python-version', sysVersion); + core.setOutput('python-path', sysExecutable); + return { impl: 'CPython', version: sysVersion }; + } } } - } - catch { - // System Python not available, fall through to error + catch { + // System Python not available, fall through to error + } } } if (!installDir) { diff --git a/src/find-python.ts b/src/find-python.ts index 9e8de0da..ed1187ce 100644 --- a/src/find-python.ts +++ b/src/find-python.ts @@ -122,31 +122,57 @@ export async function useCpythonVersion( } } - if (!installDir) { - // Try system Python as fallback (e.g., on architectures without pre-built binaries) - try { - const {exitCode, stdout} = await exec.getExecOutput('python3', [ - '-c', - 'import sys; print(sys.prefix)' - ]); - if (exitCode === 0) { - const systemPrefix = stdout.trim(); - const systemVersion = await exec.getExecOutput('python3', [ + if (!installDir && !freethreaded) { + // Try system Python as fallback, but only for architectures that have + // no pre-built binaries in the manifest at all. This prevents the + // fallback from firing when a specific version is missing on a + // supported architecture (e.g., requesting Python 3.99 on x86_64). + const baseArchitecture = architecture.replace('-freethreaded', ''); + if (!manifest) { + manifest = await installer.getManifest(); + } + const archHasManifestEntries = manifest?.some( + release => + release.files?.some( + (file: {arch: string}) => file.arch === baseArchitecture + ) + ); + + if (!archHasManifestEntries) { + try { + const sysInfo = await exec.getExecOutput('python3', [ '-c', - 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")' + 'import sys, os; print(sys.executable + "\\n" + f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + "\\n" + sys.prefix + "\\n" + os.path.dirname(sys.executable))' ]); - if ( - systemVersion.exitCode === 0 && - semver.satisfies(systemVersion.stdout.trim(), semanticVersionSpec) - ) { - installDir = systemPrefix; - core.warning( - `Pre-built Python not available for architecture '${architecture}'. Using system Python ${systemVersion.stdout.trim()} at ${systemPrefix}.` - ); + if (sysInfo.exitCode === 0) { + const [sysExecutable, sysVersion, sysPrefix, sysBinDir] = + sysInfo.stdout.trim().split('\n'); + if (semver.satisfies(sysVersion, semanticVersionSpec)) { + core.warning( + `Pre-built Python not available for architecture '${baseArchitecture}'. Using system Python ${sysVersion} at ${sysExecutable}.` + ); + + if (updateEnvironment) { + core.exportVariable('pythonLocation', sysPrefix); + core.exportVariable( + 'PKG_CONFIG_PATH', + sysPrefix + '/lib/pkgconfig' + ); + core.exportVariable('Python_ROOT_DIR', sysPrefix); + core.exportVariable('Python2_ROOT_DIR', sysPrefix); + core.exportVariable('Python3_ROOT_DIR', sysPrefix); + core.addPath(sysBinDir); + } + + core.setOutput('python-version', sysVersion); + core.setOutput('python-path', sysExecutable); + + return {impl: 'CPython', version: sysVersion}; + } } + } catch { + // System Python not available, fall through to error } - } catch { - // System Python not available, fall through to error } } From cb5cf223af5f1712fe2914b9c669d2008e051022 Mon Sep 17 00:00:00 2001 From: Bruno Verachten Date: Thu, 19 Mar 2026 14:43:18 +0100 Subject: [PATCH 3/3] test: add 5 tests for system Python fallback Signed-off-by: Bruno Verachten --- __tests__/finder.test.ts | 130 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/__tests__/finder.test.ts b/__tests__/finder.test.ts index 285a071c..f696b441 100644 --- a/__tests__/finder.test.ts +++ b/__tests__/finder.test.ts @@ -21,6 +21,7 @@ process.env['RUNNER_TEMP'] = tempDir; import * as tc from '@actions/tool-cache'; import * as core from '@actions/core'; +import * as exec from '@actions/exec'; import * as finder from '../src/find-python'; import * as installer from '../src/install-python'; @@ -298,4 +299,133 @@ describe('Finder tests', () => { expect(spyCoreAddPath).not.toHaveBeenCalled(); expect(spyCoreExportVariable).not.toHaveBeenCalled(); }); + + describe('System Python fallback', () => { + let execSpy: jest.SpyInstance; + let manifestSpy: jest.SpyInstance; + + beforeEach(() => { + // Mock the manifest to return entries only for x64, not riscv64 + manifestSpy = jest.spyOn(installer, 'getManifest'); + manifestSpy.mockImplementation( + async () => manifestData + ); + }); + + it('Falls back to system Python on unsupported architecture', async () => { + execSpy = jest.spyOn(exec, 'getExecOutput'); + execSpy.mockImplementation(async () => ({ + exitCode: 0, + stdout: + '/usr/bin/python3\n3.12.0\n/usr\n/usr/bin\n', + stderr: '' + })); + + const result = await finder.useCpythonVersion( + '3.12', + 'riscv64', + true, + false, + false, + false + ); + + expect(result).toEqual({impl: 'CPython', version: '3.12.0'}); + expect(spyCoreAddPath).toHaveBeenCalledWith('/usr/bin'); + expect(spyCoreExportVariable).toHaveBeenCalledWith( + 'pythonLocation', + '/usr' + ); + }); + + it('Does not fall back on supported architecture with missing version', async () => { + // x64 has manifest entries, so fallback should NOT trigger + let thrown = false; + try { + await finder.useCpythonVersion( + '3.300000', + 'x64', + true, + false, + false, + false + ); + } catch { + thrown = true; + } + expect(thrown).toBeTruthy(); + }); + + it('Does not fall back when system Python version does not match', async () => { + execSpy = jest.spyOn(exec, 'getExecOutput'); + execSpy.mockImplementation(async () => ({ + exitCode: 0, + stdout: + '/usr/bin/python3\n3.11.5\n/usr\n/usr/bin\n', + stderr: '' + })); + + let thrown = false; + try { + await finder.useCpythonVersion( + '3.12', + 'riscv64', + true, + false, + false, + false + ); + } catch { + thrown = true; + } + expect(thrown).toBeTruthy(); + }); + + it('Does not fall back for freethreaded builds', async () => { + execSpy = jest.spyOn(exec, 'getExecOutput'); + execSpy.mockImplementation(async () => ({ + exitCode: 0, + stdout: + '/usr/bin/python3\n3.13.0\n/usr\n/usr/bin\n', + stderr: '' + })); + + let thrown = false; + try { + await finder.useCpythonVersion( + '3.13t', + 'riscv64', + true, + false, + false, + false + ); + } catch { + thrown = true; + } + expect(thrown).toBeTruthy(); + }); + + it('Handles missing system Python gracefully', async () => { + execSpy = jest.spyOn(exec, 'getExecOutput'); + execSpy.mockImplementation(async () => { + throw new Error('python3 not found'); + }); + + let thrown = false; + try { + await finder.useCpythonVersion( + '3.12', + 'riscv64', + true, + false, + false, + false + ); + } catch { + thrown = true; + } + expect(thrown).toBeTruthy(); + }); + }); });