From 2652534ead0a7aee98976fc07bd593e6789def25 Mon Sep 17 00:00:00 2001 From: Matthieu Darbois Date: Fri, 27 Jan 2023 22:19:31 +0100 Subject: [PATCH] feature: fallback to pre-release when no stable version is found (#414) This allows to specify version like `3.11` or `pypy3.10` in workflows before those versions are released. This lessen the burden for users of `setup-python` by not having to modify their workflow twice: once when a pre-release is available (e.g. `3.11-dev`) and once when the first stable release is published (e.g. `3.11`) --- .github/workflows/test-python.yml | 31 ++++++- README.md | 1 + __tests__/data/pypy.json | 92 ++++++++++++++++++++- __tests__/data/versions-manifest.json | 43 +++++++--- __tests__/find-pypy.test.ts | 78 +++++++++++++++--- __tests__/finder.test.ts | 82 +++++++++++++++++-- __tests__/install-pypy.test.ts | 112 ++++++++++++++++++++++++-- action.yml | 3 + dist/setup/index.js | 44 +++++++--- docs/advanced-usage.md | 29 +++++++ src/find-pypy.ts | 7 +- src/find-python.ts | 22 ++++- src/install-pypy.ts | 34 +++++++- src/setup-python.ts | 7 +- 14 files changed, 524 insertions(+), 61 deletions(-) diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 4f1ffd7..354c5ca 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -149,6 +149,35 @@ jobs: - name: Run simple code run: python -c 'import math; print(math.factorial(5))' + setup-prerelease-version: + name: Setup 3.12 ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: setup-python 3.12 + id: setup-python + uses: ./ + with: + python-version: '3.12' + allow-prereleases: true + + - name: Check python-path + run: ./__tests__/check-python-path.sh '${{ steps.setup-python.outputs.python-path }}' + shell: bash + + - name: Validate version + run: ${{ startsWith(steps.setup-python.outputs.python-version, '3.12.') }} + shell: bash + + - name: Run simple code + run: python -c 'import math; print(math.factorial(5))' + setup-versions-noenv: name: Setup ${{ matrix.python }} ${{ matrix.os }} (noenv) runs-on: ${{ matrix.os }} @@ -223,4 +252,4 @@ jobs: exit 1 } $pythonVersion - shell: pwsh \ No newline at end of file + shell: pwsh diff --git a/README.md b/README.md index 08619c6..5b6efbf 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ See examples of using `cache` and `cache-dependency-path` for `pipenv` and `poet - [Hosted tool cache](docs/advanced-usage.md#hosted-tool-cache) - [Using `setup-python` with a self-hosted runner](docs/advanced-usage.md#using-setup-python-with-a-self-hosted-runner) - [Using `setup-python` on GHES](docs/advanced-usage.md#using-setup-python-on-ghes) +- [Allow pre-releases](docs/advanced-usage.md#allow-pre-releases) ## License diff --git a/__tests__/data/pypy.json b/__tests__/data/pypy.json index c8889a3..fab9bb9 100644 --- a/__tests__/data/pypy.json +++ b/__tests__/data/pypy.json @@ -1,4 +1,94 @@ [ + { + "pypy_version": "7.3.8rc2", + "python_version": "3.8.12", + "stable": false, + "latest_pypy": false, + "date": "2022-02-11", + "files": [ + { + "filename": "pypy3.8-v7.3.8rc2-linux32.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.8-v7.3.8rc2-linux32.tar.bz2" + }, + { + "filename": "pypy3.8-v7.3.8rc2-linux64.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.8-v7.3.8rc2-linux64.tar.bz2" + }, + { + "filename": "pypy3.8-v7.3.8rc2-darwin64.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "https://test.download.python.org/pypy/pypy3.8-v7.3.8rc2-darwin64.tar.bz2" + }, + { + "filename": "pypy3.8-v7.3.8rc2-s390x.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.8-v7.3.8rc2-s390x.tar.bz2" + }, + { + "filename": "pypy3.8-v7.3.8rc2-win64.zip", + "arch": "x64", + "platform": "win64", + "download_url": "https://test.download.python.org/pypy/pypy3.8-v7.3.8rc2-win64.zip" + }, + { + "filename": "pypy3.8-v7.3.8rc2-win32.zip", + "arch": "x86", + "platform": "win32", + "download_url": "https://test.download.python.org/pypy/pypy3.8-v7.3.8rc2-win32.zip" + } + ] + }, + { + "pypy_version": "7.4.0rc1", + "python_version": "3.6.12", + "stable": false, + "latest_pypy": false, + "date": "2021-11-11", + "files": [ + { + "filename": "pypy3.6-v7.4.0rc1-aarch64.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.4.0rc1-aarch64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.4.0rc1-linux32.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.4.0rc1-linux32.tar.bz2" + }, + { + "filename": "pypy3.6-v7.4.0rc1-linux64.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.4.0rc1-linux64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.4.0rc1-darwin64.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.4.0rc1-darwin64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.4.0rc1-win32.zip", + "arch": "x86", + "platform": "win32", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.4.0rc1-win32.zip" + }, + { + "filename": "pypy3.6-v7.4.0rc1-s390x.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.4.0rc1-s390x.tar.bz2" + } + ] + }, { "pypy_version": "7.3.3", "python_version": "3.6.12", @@ -530,4 +620,4 @@ } ] } -] \ No newline at end of file +] diff --git a/__tests__/data/versions-manifest.json b/__tests__/data/versions-manifest.json index 2d23f98..083160f 100644 --- a/__tests__/data/versions-manifest.json +++ b/__tests__/data/versions-manifest.json @@ -1,4 +1,29 @@ [ + { + "version": "1.2.4-beta.2", + "stable": false, + "release_url": "https://github.com/actions/sometool/releases/tag/1.2.4-beta.2-20200402.5", + "files": [ + { + "filename": "sometool-1.2.4-linux-x64.tar.gz", + "arch": "x64", + "platform": "linux", + "download_url": "https://github.com/actions/sometool/releases/tag/1.2.4-beta.2-20200402.5/sometool-1.2.4-linux-x64.tar.gz" + }, + { + "filename": "sometool-1.2.4-darwin-x64.tar.gz", + "arch": "x64", + "platform": "darwin", + "download_url": "https://github.com/actions/sometool/releases/tag/1.2.4-beta.2-20200402.5/sometool-1.2.4-darwin-x64.tar.gz" + }, + { + "filename": "sometool-1.2.4-win32-x64.tar.gz", + "arch": "x64", + "platform": "win32", + "download_url": "https://github.com/actions/sometool/releases/tag/1.2.4-beta.2-20200402.5/sometool-1.2.4-win32-x64.tar.gz" + } + ] + }, { "version": "1.2.3", "stable": true, @@ -25,28 +50,28 @@ ] }, { - "version": "1.2.3-beta.2", + "version": "1.1.0-beta.2", "stable": false, - "release_url": "https://github.com/actions/sometool/releases/tag/1.2.3-beta.2-20200402.5", + "release_url": "https://github.com/actions/sometool/releases/tag/1.1.0-beta.2-20200402.5", "files": [ { - "filename": "sometool-1.2.3-linux-x64.tar.gz", + "filename": "sometool-1.1.0-linux-x64.tar.gz", "arch": "x64", "platform": "linux", - "download_url": "https://github.com/actions/sometool/releases/tag/1.2.3-beta.2-20200402.5/sometool-1.2.3-linux-x64.tar.gz" + "download_url": "https://github.com/actions/sometool/releases/tag/1.1.0-beta.2-20200402.5/sometool-1.1.0-linux-x64.tar.gz" }, { - "filename": "sometool-1.2.3-darwin-x64.tar.gz", + "filename": "sometool-1.1.0-darwin-x64.tar.gz", "arch": "x64", "platform": "darwin", - "download_url": "https://github.com/actions/sometool/releases/tag/1.2.3-20200402.5/sometool-1.2.3-darwin-x64.tar.gz" + "download_url": "https://github.com/actions/sometool/releases/tag/1.1.0-beta.2-20200402.5/sometool-1.1.0-darwin-x64.tar.gz" }, { - "filename": "sometool-1.2.3-win32-x64.tar.gz", + "filename": "sometool-1.1.0-win32-x64.tar.gz", "arch": "x64", "platform": "win32", - "download_url": "https://github.com/actions/sometool/releases/tag/1.2.3-20200402.5/sometool-1.2.3-win32-x64.tar.gz" + "download_url": "https://github.com/actions/sometool/releases/tag/1.1.0-beta.2-20200402.5/sometool-1.1.0-win32-x64.tar.gz" } ] } -] \ No newline at end of file +] diff --git a/__tests__/find-pypy.test.ts b/__tests__/find-pypy.test.ts index 660f23d..66be1f1 100644 --- a/__tests__/find-pypy.test.ts +++ b/__tests__/find-pypy.test.ts @@ -273,7 +273,13 @@ describe('findPyPyVersion', () => { it('found PyPy in toolcache', async () => { await expect( - finder.findPyPyVersion('pypy-3.6-v7.3.x', architecture, true, false) + finder.findPyPyVersion( + 'pypy-3.6-v7.3.x', + architecture, + true, + false, + false + ) ).resolves.toEqual({ resolvedPythonVersion: '3.6.12', resolvedPyPyVersion: '7.3.3' @@ -291,13 +297,13 @@ describe('findPyPyVersion', () => { it('throw on invalid input format', async () => { await expect( - finder.findPyPyVersion('pypy3.7-v7.3.x', architecture, true, false) + finder.findPyPyVersion('pypy3.7-v7.3.x', architecture, true, false, false) ).rejects.toThrow(); }); it('throw on invalid input format pypy3.7-7.3.x', async () => { await expect( - finder.findPyPyVersion('pypy3.7-v7.3.x', architecture, true, false) + finder.findPyPyVersion('pypy3.7-v7.3.x', architecture, true, false, false) ).rejects.toThrow(); }); @@ -309,7 +315,13 @@ describe('findPyPyVersion', () => { spyChmodSync = jest.spyOn(fs, 'chmodSync'); spyChmodSync.mockImplementation(() => undefined); await expect( - finder.findPyPyVersion('pypy-3.7-v7.3.x', architecture, true, false) + finder.findPyPyVersion( + 'pypy-3.7-v7.3.x', + architecture, + true, + false, + false + ) ).resolves.toEqual({ resolvedPythonVersion: '3.7.9', resolvedPyPyVersion: '7.3.3' @@ -333,7 +345,13 @@ describe('findPyPyVersion', () => { spyChmodSync = jest.spyOn(fs, 'chmodSync'); spyChmodSync.mockImplementation(() => undefined); await expect( - finder.findPyPyVersion('pypy-3.7-v7.3.x', architecture, false, false) + finder.findPyPyVersion( + 'pypy-3.7-v7.3.x', + architecture, + false, + false, + false + ) ).resolves.toEqual({ resolvedPythonVersion: '3.7.9', resolvedPyPyVersion: '7.3.3' @@ -344,7 +362,13 @@ describe('findPyPyVersion', () => { it('throw if release is not found', async () => { await expect( - finder.findPyPyVersion('pypy-3.7-v7.5.x', architecture, true, false) + finder.findPyPyVersion( + 'pypy-3.7-v7.5.x', + architecture, + true, + false, + false + ) ).rejects.toThrowError( `PyPy version 3.7 (v7.5.x) with arch ${architecture} not found` ); @@ -352,7 +376,13 @@ describe('findPyPyVersion', () => { it('check-latest enabled version found and used from toolcache', async () => { await expect( - finder.findPyPyVersion('pypy-3.6-v7.3.x', architecture, false, true) + finder.findPyPyVersion( + 'pypy-3.6-v7.3.x', + architecture, + false, + true, + false + ) ).resolves.toEqual({ resolvedPythonVersion: '3.6.12', resolvedPyPyVersion: '7.3.3' @@ -371,7 +401,13 @@ describe('findPyPyVersion', () => { spyChmodSync = jest.spyOn(fs, 'chmodSync'); spyChmodSync.mockImplementation(() => undefined); await expect( - finder.findPyPyVersion('pypy-3.7-v7.3.x', architecture, false, true) + finder.findPyPyVersion( + 'pypy-3.7-v7.3.x', + architecture, + false, + true, + false + ) ).resolves.toEqual({ resolvedPythonVersion: '3.7.9', resolvedPyPyVersion: '7.3.3' @@ -391,7 +427,13 @@ describe('findPyPyVersion', () => { return pypyPath; }); await expect( - finder.findPyPyVersion('pypy-3.8-v7.3.x', architecture, false, true) + finder.findPyPyVersion( + 'pypy-3.8-v7.3.x', + architecture, + false, + true, + false + ) ).resolves.toEqual({ resolvedPythonVersion: '3.8.8', resolvedPyPyVersion: '7.3.3' @@ -401,4 +443,22 @@ describe('findPyPyVersion', () => { 'Failed to resolve PyPy v7.3.x with Python (3.8) from manifest' ); }); + + it('found and install successfully, pre-release fallback', async () => { + spyCacheDir = jest.spyOn(tc, 'cacheDir'); + spyCacheDir.mockImplementation(() => + path.join(toolDir, 'PyPy', '3.8.12', architecture) + ); + spyChmodSync = jest.spyOn(fs, 'chmodSync'); + spyChmodSync.mockImplementation(() => undefined); + await expect( + finder.findPyPyVersion('pypy3.8', architecture, false, false, false) + ).rejects.toThrowError(); + await expect( + finder.findPyPyVersion('pypy3.8', architecture, false, false, true) + ).resolves.toEqual({ + resolvedPythonVersion: '3.8.12', + resolvedPyPyVersion: '7.3.8rc2' + }); + }); }); diff --git a/__tests__/finder.test.ts b/__tests__/finder.test.ts index d2fe775..b26c709 100644 --- a/__tests__/finder.test.ts +++ b/__tests__/finder.test.ts @@ -56,7 +56,7 @@ describe('Finder tests', () => { await io.mkdirP(pythonDir); fs.writeFileSync(`${pythonDir}.complete`, 'hello'); // This will throw if it doesn't find it in the cache and in the manifest (because no such version exists) - await finder.useCpythonVersion('3.x', 'x64', true, false); + await finder.useCpythonVersion('3.x', 'x64', true, false, false); expect(spyCoreAddPath).toHaveBeenCalled(); expect(spyCoreExportVariable).toHaveBeenCalledWith( 'pythonLocation', @@ -73,7 +73,7 @@ describe('Finder tests', () => { await io.mkdirP(pythonDir); fs.writeFileSync(`${pythonDir}.complete`, 'hello'); // This will throw if it doesn't find it in the cache and in the manifest (because no such version exists) - await finder.useCpythonVersion('3.x', 'x64', false, false); + await finder.useCpythonVersion('3.x', 'x64', false, false, false); expect(spyCoreAddPath).not.toHaveBeenCalled(); expect(spyCoreExportVariable).not.toHaveBeenCalled(); }); @@ -95,7 +95,12 @@ describe('Finder tests', () => { fs.writeFileSync(`${pythonDir}.complete`, 'hello'); }); // This will throw if it doesn't find it in the cache and in the manifest (because no such version exists) - await finder.useCpythonVersion('1.2.3', 'x64', true, false); + await expect( + finder.useCpythonVersion('1.2.3', 'x64', true, false, false) + ).resolves.toEqual({ + impl: 'CPython', + version: '1.2.3' + }); expect(spyCoreAddPath).toHaveBeenCalled(); expect(spyCoreExportVariable).toHaveBeenCalledWith( 'pythonLocation', @@ -122,14 +127,19 @@ describe('Finder tests', () => { const pythonDir: string = path.join( toolDir, 'Python', - '1.2.3-beta.2', + '1.2.4-beta.2', 'x64' ); await io.mkdirP(pythonDir); fs.writeFileSync(`${pythonDir}.complete`, 'hello'); }); // This will throw if it doesn't find it in the manifest (because no such version exists) - await finder.useCpythonVersion('1.2.3-beta.2', 'x64', false, false); + await expect( + finder.useCpythonVersion('1.2.4-beta.2', 'x64', false, false, false) + ).resolves.toEqual({ + impl: 'CPython', + version: '1.2.4-beta.2' + }); }); it('Check-latest true, finds the latest version in the manifest', async () => { @@ -176,7 +186,7 @@ describe('Finder tests', () => { fs.writeFileSync(`${pythonDir}.complete`, 'hello'); // This will throw if it doesn't find it in the cache and in the manifest (because no such version exists) - await finder.useCpythonVersion('1.2', 'x64', true, true); + await finder.useCpythonVersion('1.2', 'x64', true, true, false); expect(infoSpy).toHaveBeenCalledWith("Resolved as '1.2.3'"); expect(infoSpy).toHaveBeenCalledWith( @@ -187,7 +197,7 @@ describe('Finder tests', () => { ); expect(installSpy).toHaveBeenCalled(); expect(addPathSpy).toHaveBeenCalledWith(expPath); - await finder.useCpythonVersion('1.2.3-beta.2', 'x64', false, true); + await finder.useCpythonVersion('1.2.4-beta.2', 'x64', false, true, false); expect(spyCoreAddPath).toHaveBeenCalled(); expect(spyCoreExportVariable).toHaveBeenCalledWith( 'pythonLocation', @@ -199,11 +209,67 @@ describe('Finder tests', () => { ); }); + it('Finds stable Python version if it is not installed, but exists in the manifest, skipping newer pre-release', async () => { + const findSpy: jest.SpyInstance = jest.spyOn(tc, 'getManifestFromRepo'); + findSpy.mockImplementation(() => manifestData); + + const installSpy: jest.SpyInstance = jest.spyOn( + installer, + 'installCpythonFromRelease' + ); + installSpy.mockImplementation(async () => { + const pythonDir: string = path.join(toolDir, 'Python', '1.2.3', 'x64'); + await io.mkdirP(pythonDir); + fs.writeFileSync(`${pythonDir}.complete`, 'hello'); + }); + // This will throw if it doesn't find it in the cache and in the manifest (because no such version exists) + await expect( + finder.useCpythonVersion('1.2', 'x64', false, false, false) + ).resolves.toEqual({ + impl: 'CPython', + version: '1.2.3' + }); + }); + + it('Finds Python version if it is not installed, but exists in the manifest, pre-release fallback', async () => { + const findSpy: jest.SpyInstance = jest.spyOn(tc, 'getManifestFromRepo'); + findSpy.mockImplementation(() => manifestData); + + const installSpy: jest.SpyInstance = jest.spyOn( + installer, + 'installCpythonFromRelease' + ); + installSpy.mockImplementation(async () => { + const pythonDir: string = path.join( + toolDir, + 'Python', + '1.1.0-beta.2', + 'x64' + ); + await io.mkdirP(pythonDir); + fs.writeFileSync(`${pythonDir}.complete`, 'hello'); + }); + // This will throw if it doesn't find it in the cache and in the manifest (because no such version exists) + await expect( + finder.useCpythonVersion('1.1', 'x64', false, false, false) + ).rejects.toThrowError(); + await expect( + finder.useCpythonVersion('1.1', 'x64', false, false, true) + ).resolves.toEqual({ + impl: 'CPython', + version: '1.1.0-beta.2' + }); + // Check 1.1.0 version specifier does not fallback to '1.1.0-beta.2' + await expect( + finder.useCpythonVersion('1.1.0', 'x64', false, false, true) + ).rejects.toThrowError(); + }); + it('Errors if Python is not installed', async () => { // This will throw if it doesn't find it in the cache and in the manifest (because no such version exists) let thrown = false; try { - await finder.useCpythonVersion('3.300000', 'x64', true, false); + await finder.useCpythonVersion('3.300000', 'x64', true, false, false); } catch { thrown = true; } diff --git a/__tests__/install-pypy.test.ts b/__tests__/install-pypy.test.ts index ae7fb4a..d08daa2 100644 --- a/__tests__/install-pypy.test.ts +++ b/__tests__/install-pypy.test.ts @@ -51,6 +51,12 @@ describe('findRelease', () => { platform: process.platform, download_url: `https://test.download.python.org/pypy/pypy3.6-v7.3.3-${extensionName}` }; + const filesRC1: IPyPyManifestAsset = { + filename: `pypy3.6-v7.4.0rc1-${extensionName}`, + arch: architecture, + platform: process.platform, + download_url: `https://test.download.python.org/pypy/pypy3.6-v7.4.0rc1-${extensionName}` + }; let getBooleanInputSpy: jest.SpyInstance; let warningSpy: jest.SpyInstance; @@ -72,7 +78,13 @@ describe('findRelease', () => { const pythonVersion = '3.6'; const pypyVersion = '7.3.7'; expect( - installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + installer.findRelease( + releases, + pythonVersion, + pypyVersion, + architecture, + false + ) ).toEqual(null); }); @@ -80,7 +92,13 @@ describe('findRelease', () => { const pythonVersion = '3.6'; const pypyVersion = '7.3.3'; expect( - installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + installer.findRelease( + releases, + pythonVersion, + pypyVersion, + architecture, + false + ) ).toEqual({ foundAsset: files, resolvedPythonVersion: '3.6.12', @@ -92,7 +110,13 @@ describe('findRelease', () => { const pythonVersion = '3.6'; const pypyVersion = '7.x'; expect( - installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + installer.findRelease( + releases, + pythonVersion, + pypyVersion, + architecture, + false + ) ).toEqual({ foundAsset: files, resolvedPythonVersion: '3.6.12', @@ -104,7 +128,13 @@ describe('findRelease', () => { const pythonVersion = '3.7'; const pypyVersion = installer.pypyVersionToSemantic('7.3.3rc2'); expect( - installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + installer.findRelease( + releases, + pythonVersion, + pypyVersion, + architecture, + false + ) ).toEqual({ foundAsset: { filename: `test${extension}`, @@ -121,7 +151,13 @@ describe('findRelease', () => { const pythonVersion = '3.6'; const pypyVersion = 'x'; expect( - installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + installer.findRelease( + releases, + pythonVersion, + pypyVersion, + architecture, + false + ) ).toEqual({ foundAsset: files, resolvedPythonVersion: '3.6.12', @@ -129,12 +165,45 @@ describe('findRelease', () => { }); }); + it('Python version and PyPy version matches semver (pre-release)', () => { + const pythonVersion = '3.6'; + const pypyVersion = '7.4.x'; + expect( + installer.findRelease( + releases, + pythonVersion, + pypyVersion, + architecture, + false + ) + ).toBeNull(); + expect( + installer.findRelease( + releases, + pythonVersion, + pypyVersion, + architecture, + true + ) + ).toEqual({ + foundAsset: filesRC1, + resolvedPythonVersion: '3.6.12', + resolvedPyPyVersion: '7.4.0rc1' + }); + }); + it('Nightly release is found', () => { const pythonVersion = '3.6'; const pypyVersion = 'nightly'; const filename = IS_WINDOWS ? 'filename.zip' : 'filename.tar.bz2'; expect( - installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + installer.findRelease( + releases, + pythonVersion, + pypyVersion, + architecture, + false + ) ).toEqual({ foundAsset: { filename: filename, @@ -224,7 +293,7 @@ describe('installPyPy', () => { it('throw if release is not found', async () => { await expect( - installer.installPyPy('7.3.3', '3.6.17', architecture, undefined) + installer.installPyPy('7.3.3', '3.6.17', architecture, false, undefined) ).rejects.toThrowError( `PyPy version 3.6.17 (7.3.3) with arch ${architecture} not found` ); @@ -244,7 +313,7 @@ describe('installPyPy', () => { spyChmodSync.mockImplementation(() => undefined); await expect( - installer.installPyPy('7.3.x', '3.6.12', architecture, undefined) + installer.installPyPy('7.x', '3.6.12', architecture, false, undefined) ).resolves.toEqual({ installDir: path.join(toolDir, 'PyPy', '3.6.12', architecture), resolvedPythonVersion: '3.6.12', @@ -257,4 +326,31 @@ describe('installPyPy', () => { expect(spyCacheDir).toHaveBeenCalled(); expect(spyExec).toHaveBeenCalled(); }); + + it('found and install PyPy, pre-release fallback', async () => { + spyCacheDir = jest.spyOn(tc, 'cacheDir'); + spyCacheDir.mockImplementation(() => + path.join(toolDir, 'PyPy', '3.6.12', architecture) + ); + + spyChmodSync = jest.spyOn(fs, 'chmodSync'); + spyChmodSync.mockImplementation(() => undefined); + + await expect( + installer.installPyPy('7.4.x', '3.6.12', architecture, false, undefined) + ).rejects.toThrowError(); + await expect( + installer.installPyPy('7.4.x', '3.6.12', architecture, true, undefined) + ).resolves.toEqual({ + installDir: path.join(toolDir, 'PyPy', '3.6.12', architecture), + resolvedPythonVersion: '3.6.12', + resolvedPyPyVersion: '7.4.0rc1' + }); + + expect(spyHttpClient).toHaveBeenCalled(); + expect(spyDownloadTool).toHaveBeenCalled(); + expect(spyExistsSync).toHaveBeenCalled(); + expect(spyCacheDir).toHaveBeenCalled(); + expect(spyExec).toHaveBeenCalled(); + }); }); diff --git a/action.yml b/action.yml index b8bb06b..3a6531c 100644 --- a/action.yml +++ b/action.yml @@ -23,6 +23,9 @@ inputs: update-environment: description: "Set this option if you want the action to update environment variables." default: true + allow-prereleases: + description: "When 'true', a version range passed to 'python-version' input will match prerelease versions if no GA versions are found. Only 'x.y' version range is supported for CPython." + default: false outputs: python-version: description: "The installed Python or PyPy version. Useful when given a version range as input." diff --git a/dist/setup/index.js b/dist/setup/index.js index 4cf2767..18cc54b 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -66237,7 +66237,7 @@ const utils_1 = __nccwpck_require__(1314); const semver = __importStar(__nccwpck_require__(1383)); const core = __importStar(__nccwpck_require__(2186)); const tc = __importStar(__nccwpck_require__(7784)); -function findPyPyVersion(versionSpec, architecture, updateEnvironment, checkLatest) { +function findPyPyVersion(versionSpec, architecture, updateEnvironment, checkLatest, allowPreReleases) { return __awaiter(this, void 0, void 0, function* () { let resolvedPyPyVersion = ''; let resolvedPythonVersion = ''; @@ -66247,7 +66247,7 @@ function findPyPyVersion(versionSpec, architecture, updateEnvironment, checkLate if (checkLatest) { releases = yield pypyInstall.getAvailablePyPyVersions(); if (releases && releases.length > 0) { - const releaseData = pypyInstall.findRelease(releases, pypyVersionSpec.pythonVersion, pypyVersionSpec.pypyVersion, architecture); + const releaseData = pypyInstall.findRelease(releases, pypyVersionSpec.pythonVersion, pypyVersionSpec.pypyVersion, architecture, false); if (releaseData) { core.info(`Resolved as PyPy ${releaseData.resolvedPyPyVersion} with Python (${releaseData.resolvedPythonVersion})`); pypyVersionSpec.pythonVersion = releaseData.resolvedPythonVersion; @@ -66264,7 +66264,7 @@ function findPyPyVersion(versionSpec, architecture, updateEnvironment, checkLate installDir, resolvedPythonVersion, resolvedPyPyVersion - } = yield pypyInstall.installPyPy(pypyVersionSpec.pypyVersion, pypyVersionSpec.pythonVersion, architecture, releases)); + } = yield pypyInstall.installPyPy(pypyVersionSpec.pypyVersion, pypyVersionSpec.pythonVersion, architecture, allowPreReleases, releases)); } const pipDir = utils_1.IS_WINDOWS ? 'Scripts' : 'bin'; const _binDir = path.join(installDir, pipDir); @@ -66414,12 +66414,12 @@ function binDir(installDir) { return path.join(installDir, 'bin'); } } -function useCpythonVersion(version, architecture, updateEnvironment, checkLatest) { +function useCpythonVersion(version, architecture, updateEnvironment, checkLatest, allowPreReleases) { var _a; return __awaiter(this, void 0, void 0, function* () { let manifest = null; const desugaredVersionSpec = desugarDevVersion(version); - let semanticVersionSpec = pythonVersionToSemantic(desugaredVersionSpec); + let semanticVersionSpec = pythonVersionToSemantic(desugaredVersionSpec, allowPreReleases); core.debug(`Semantic version spec of ${version} is ${semanticVersionSpec}`); if (checkLatest) { manifest = yield installer.getManifest(); @@ -66510,10 +66510,17 @@ function versionFromPath(installDir) { * Python's prelease versions look like `3.7.0b2`. * This is the one part of Python versioning that does not look like semantic versioning, which specifies `3.7.0-b2`. * If the version spec contains prerelease versions, we need to convert them to the semantic version equivalent. + * + * For easier use of the action, we also map 'x.y' to allow pre-release before 'x.y.0' release if allowPreReleases is true */ -function pythonVersionToSemantic(versionSpec) { +function pythonVersionToSemantic(versionSpec, allowPreReleases) { const prereleaseVersion = /(\d+\.\d+\.\d+)((?:a|b|rc)\d*)/g; - return versionSpec.replace(prereleaseVersion, '$1-$2'); + const majorMinor = /^(\d+)\.(\d+)$/; + let result = versionSpec.replace(prereleaseVersion, '$1-$2'); + if (allowPreReleases) { + result = result.replace(majorMinor, '~$1.$2.0-0'); + } + return result; } exports.pythonVersionToSemantic = pythonVersionToSemantic; @@ -66558,6 +66565,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.findAssetForMacOrLinux = exports.findAssetForWindows = exports.isArchPresentForMacOrLinux = exports.isArchPresentForWindows = exports.pypyVersionToSemantic = exports.getPyPyBinaryPath = exports.findRelease = exports.getAvailablePyPyVersions = exports.installPyPy = void 0; +const os = __importStar(__nccwpck_require__(2037)); const path = __importStar(__nccwpck_require__(1017)); const core = __importStar(__nccwpck_require__(2186)); const tc = __importStar(__nccwpck_require__(7784)); @@ -66566,14 +66574,22 @@ const httpm = __importStar(__nccwpck_require__(9925)); const exec = __importStar(__nccwpck_require__(1514)); const fs_1 = __importDefault(__nccwpck_require__(7147)); const utils_1 = __nccwpck_require__(1314); -function installPyPy(pypyVersion, pythonVersion, architecture, releases) { +function installPyPy(pypyVersion, pythonVersion, architecture, allowPreReleases, releases) { return __awaiter(this, void 0, void 0, function* () { let downloadDir; releases = releases !== null && releases !== void 0 ? releases : (yield getAvailablePyPyVersions()); if (!releases || releases.length === 0) { throw new Error('No release was found in PyPy version.json'); } - const releaseData = findRelease(releases, pythonVersion, pypyVersion, architecture); + let releaseData = findRelease(releases, pythonVersion, pypyVersion, architecture, false); + if (allowPreReleases && (!releaseData || !releaseData.foundAsset)) { + // check for pre-release + core.info([ + `Stable PyPy version ${pythonVersion} (${pypyVersion}) with arch ${architecture} not found`, + `Trying pre-release versions` + ].join(os.EOL)); + releaseData = findRelease(releases, pythonVersion, pypyVersion, architecture, true); + } if (!releaseData || !releaseData.foundAsset) { throw new Error(`PyPy version ${pythonVersion} (${pypyVersion}) with arch ${architecture} not found`); } @@ -66656,12 +66672,13 @@ function installPip(pythonLocation) { yield exec.exec(`${pythonLocation}/python -m pip install --ignore-installed pip`); }); } -function findRelease(releases, pythonVersion, pypyVersion, architecture) { +function findRelease(releases, pythonVersion, pypyVersion, architecture, includePrerelease) { + const options = { includePrerelease: includePrerelease }; const filterReleases = releases.filter(item => { const isPythonVersionSatisfied = semver.satisfies(semver.coerce(item.python_version), pythonVersion); const isPyPyNightly = utils_1.isNightlyKeyword(pypyVersion) && utils_1.isNightlyKeyword(item.pypy_version); const isPyPyVersionSatisfied = isPyPyNightly || - semver.satisfies(pypyVersionToSemantic(item.pypy_version), pypyVersion); + semver.satisfies(pypyVersionToSemantic(item.pypy_version), pypyVersion, options); const isArchPresent = item.files && (utils_1.IS_WINDOWS ? isArchPresentForWindows(item, architecture) @@ -66948,6 +66965,7 @@ function run() { try { const versions = resolveVersionInput(); const checkLatest = core.getBooleanInput('check-latest'); + const allowPreReleases = core.getBooleanInput('allow-prereleases'); if (versions.length) { let pythonVersion = ''; const arch = core.getInput('architecture') || os.arch(); @@ -66955,12 +66973,12 @@ function run() { core.startGroup('Installed versions'); for (const version of versions) { if (isPyPyVersion(version)) { - const installed = yield finderPyPy.findPyPyVersion(version, arch, updateEnvironment, checkLatest); + const installed = yield finderPyPy.findPyPyVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases); pythonVersion = `${installed.resolvedPyPyVersion}-${installed.resolvedPythonVersion}`; core.info(`Successfully set up PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})`); } else { - const installed = yield finder.useCpythonVersion(version, arch, updateEnvironment, checkLatest); + const installed = yield finder.useCpythonVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases); pythonVersion = installed.version; core.info(`Successfully set up ${installed.impl} (${pythonVersion})`); } diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index d752898..50b3c81 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -20,6 +20,7 @@ - [Linux](advanced-usage.md#linux) - [macOS](advanced-usage.md#macos) - [Using `setup-python` on GHES](advanced-usage.md#using-setup-python-on-ghes) +- [Allow pre-releases](advanced-usage.md#allow-pre-releases) ## Using the `python-version` input @@ -568,3 +569,31 @@ Requests should now be authenticated. To verify that you are getting the higher ### No access to github.com If the runner is not able to access github.com, any Python versions requested during a workflow run must come from the runner's tool cache. See "[Setting up the tool cache on self-hosted runners without internet access](https://docs.github.com/en/enterprise-server@3.2/admin/github-actions/managing-access-to-actions-from-githubcom/setting-up-the-tool-cache-on-self-hosted-runners-without-internet-access)" for more information. + + +## Allow pre-releases + +The `allow-prereleases` flag defaults to `false`. +If `allow-prereleases` is set to `true`, the action will allow falling back to pre-release versions of Python when a matching GA version of Python is not available. +This allows for example to simplify reuse of `python-version` as an input of nox for pre-releases of Python by not requiring manipulation of the `3.y-dev` specifier. +For CPython, `allow-prereleases` will only have effect for `x.y` version range (e.g. `3.12`). +Let's say that python 3.12 is not generally available, the following workflow will fallback to the most recent pre-release of python 3.12: +```yaml +jobs: + test: + name: ${{ matrix.os }} / ${{ matrix.python_version }} + runs-on: ${{ matrix.os }}-latest + strategy: + fail-fast: false + matrix: + os: [Ubuntu, Windows, macOS] + python_version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "${{ matrix.python_version }}" + - run: pipx run nox --error-on-missing-interpreters -s tests-${{ matrix.python_version }} +``` + diff --git a/src/find-pypy.ts b/src/find-pypy.ts index 20b9821..44bfd5a 100644 --- a/src/find-pypy.ts +++ b/src/find-pypy.ts @@ -23,7 +23,8 @@ export async function findPyPyVersion( versionSpec: string, architecture: string, updateEnvironment: boolean, - checkLatest: boolean + checkLatest: boolean, + allowPreReleases: boolean ): Promise<{resolvedPyPyVersion: string; resolvedPythonVersion: string}> { let resolvedPyPyVersion = ''; let resolvedPythonVersion = ''; @@ -39,7 +40,8 @@ export async function findPyPyVersion( releases, pypyVersionSpec.pythonVersion, pypyVersionSpec.pypyVersion, - architecture + architecture, + false ); if (releaseData) { @@ -71,6 +73,7 @@ export async function findPyPyVersion( pypyVersionSpec.pypyVersion, pypyVersionSpec.pythonVersion, architecture, + allowPreReleases, releases )); } diff --git a/src/find-python.ts b/src/find-python.ts index c156d2a..5a760d6 100644 --- a/src/find-python.ts +++ b/src/find-python.ts @@ -34,11 +34,15 @@ export async function useCpythonVersion( version: string, architecture: string, updateEnvironment: boolean, - checkLatest: boolean + checkLatest: boolean, + allowPreReleases: boolean ): Promise { let manifest: tc.IToolRelease[] | null = null; const desugaredVersionSpec = desugarDevVersion(version); - let semanticVersionSpec = pythonVersionToSemantic(desugaredVersionSpec); + let semanticVersionSpec = pythonVersionToSemantic( + desugaredVersionSpec, + allowPreReleases + ); core.debug(`Semantic version spec of ${version} is ${semanticVersionSpec}`); if (checkLatest) { @@ -178,8 +182,18 @@ interface InstalledVersion { * Python's prelease versions look like `3.7.0b2`. * This is the one part of Python versioning that does not look like semantic versioning, which specifies `3.7.0-b2`. * If the version spec contains prerelease versions, we need to convert them to the semantic version equivalent. + * + * For easier use of the action, we also map 'x.y' to allow pre-release before 'x.y.0' release if allowPreReleases is true */ -export function pythonVersionToSemantic(versionSpec: string) { +export function pythonVersionToSemantic( + versionSpec: string, + allowPreReleases: boolean +) { const prereleaseVersion = /(\d+\.\d+\.\d+)((?:a|b|rc)\d*)/g; - return versionSpec.replace(prereleaseVersion, '$1-$2'); + const majorMinor = /^(\d+)\.(\d+)$/; + let result = versionSpec.replace(prereleaseVersion, '$1-$2'); + if (allowPreReleases) { + result = result.replace(majorMinor, '~$1.$2.0-0'); + } + return result; } diff --git a/src/install-pypy.ts b/src/install-pypy.ts index f7df9c5..9327d62 100644 --- a/src/install-pypy.ts +++ b/src/install-pypy.ts @@ -1,3 +1,4 @@ +import * as os from 'os'; import * as path from 'path'; import * as core from '@actions/core'; import * as tc from '@actions/tool-cache'; @@ -19,6 +20,7 @@ export async function installPyPy( pypyVersion: string, pythonVersion: string, architecture: string, + allowPreReleases: boolean, releases: IPyPyManifestRelease[] | undefined ) { let downloadDir; @@ -29,13 +31,31 @@ export async function installPyPy( throw new Error('No release was found in PyPy version.json'); } - const releaseData = findRelease( + let releaseData = findRelease( releases, pythonVersion, pypyVersion, - architecture + architecture, + false ); + if (allowPreReleases && (!releaseData || !releaseData.foundAsset)) { + // check for pre-release + core.info( + [ + `Stable PyPy version ${pythonVersion} (${pypyVersion}) with arch ${architecture} not found`, + `Trying pre-release versions` + ].join(os.EOL) + ); + releaseData = findRelease( + releases, + pythonVersion, + pypyVersion, + architecture, + true + ); + } + if (!releaseData || !releaseData.foundAsset) { throw new Error( `PyPy version ${pythonVersion} (${pypyVersion}) with arch ${architecture} not found` @@ -162,8 +182,10 @@ export function findRelease( releases: IPyPyManifestRelease[], pythonVersion: string, pypyVersion: string, - architecture: string + architecture: string, + includePrerelease: boolean ) { + const options = {includePrerelease: includePrerelease}; const filterReleases = releases.filter(item => { const isPythonVersionSatisfied = semver.satisfies( semver.coerce(item.python_version)!, @@ -173,7 +195,11 @@ export function findRelease( isNightlyKeyword(pypyVersion) && isNightlyKeyword(item.pypy_version); const isPyPyVersionSatisfied = isPyPyNightly || - semver.satisfies(pypyVersionToSemantic(item.pypy_version), pypyVersion); + semver.satisfies( + pypyVersionToSemantic(item.pypy_version), + pypyVersion, + options + ); const isArchPresent = item.files && (IS_WINDOWS diff --git a/src/setup-python.ts b/src/setup-python.ts index 0089b40..3be958a 100644 --- a/src/setup-python.ts +++ b/src/setup-python.ts @@ -77,6 +77,7 @@ async function run() { try { const versions = resolveVersionInput(); const checkLatest = core.getBooleanInput('check-latest'); + const allowPreReleases = core.getBooleanInput('allow-prereleases'); if (versions.length) { let pythonVersion = ''; @@ -89,7 +90,8 @@ async function run() { version, arch, updateEnvironment, - checkLatest + checkLatest, + allowPreReleases ); pythonVersion = `${installed.resolvedPyPyVersion}-${installed.resolvedPythonVersion}`; core.info( @@ -100,7 +102,8 @@ async function run() { version, arch, updateEnvironment, - checkLatest + checkLatest, + allowPreReleases ); pythonVersion = installed.version; core.info(`Successfully set up ${installed.impl} (${pythonVersion})`);