diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..9ec45a506 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @actions/actions-service diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f12088901..d26264db1 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,6 +2,7 @@ name: "Code scanning - action" on: push: + pull_request: schedule: - cron: '25 3 * * 5' diff --git a/.github/workflows/test-pypy.yml b/.github/workflows/test-pypy.yml new file mode 100644 index 000000000..547d03419 --- /dev/null +++ b/.github/workflows/test-pypy.yml @@ -0,0 +1,47 @@ +name: Validate PyPy e2e +on: + push: + branches: + - main + paths-ignore: + - '**.md' + pull_request: + paths-ignore: + - '**.md' + schedule: + - cron: 30 3 * * * + +jobs: + setup-pypy: + name: Setup PyPy ${{ matrix.pypy }} ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, ubuntu-18.04, ubuntu-latest] + pypy: + - 'pypy-2.7' + - 'pypy-3.6' + - 'pypy-3.7' + - 'pypy-2.7-v7.3.2' + - 'pypy-3.6-v7.3.2' + - 'pypy-3.7-v7.3.2' + - 'pypy-3.6-v7.3.x' + - 'pypy-3.7-v7.x' + - 'pypy-2.7-v7.3.4rc1' + - 'pypy-3.7-nightly' + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: setup-python ${{ matrix.pypy }} + uses: ./ + with: + python-version: ${{ matrix.pypy }} + + - name: PyPy and Python version + run: python --version + + - name: Run simple code + run: python -c 'import math; print(math.factorial(5))' diff --git a/.github/workflows/test.yml b/.github/workflows/test-python.yml similarity index 79% rename from .github/workflows/test.yml rename to .github/workflows/test-python.yml index cc23018a9..3a85ee22d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test-python.yml @@ -1,4 +1,4 @@ -name: Validate 'setup-python' +name: Validate Python e2e on: push: branches: @@ -9,7 +9,7 @@ on: paths-ignore: - '**.md' schedule: - - cron: 0 0 * * * + - cron: 30 3 * * * jobs: default-version: @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest, ubuntu-16.04, ubuntu-18.04] + os: [macos-latest, windows-latest, ubuntu-16.04, ubuntu-18.04, ubuntu-20.04] steps: - name: Checkout uses: actions/checkout@v2 @@ -38,7 +38,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest, ubuntu-16.04, ubuntu-18.04] + os: [macos-latest, windows-latest, ubuntu-16.04, ubuntu-18.04, ubuntu-20.04] python: [3.5.4, 3.6.7, 3.7.5, 3.8.1] steps: - name: Checkout @@ -68,7 +68,7 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, windows-latest, ubuntu-16.04, ubuntu-18.04] + os: [macos-latest, windows-latest, ubuntu-16.04, ubuntu-18.04, ubuntu-20.04] steps: - name: Checkout uses: actions/checkout@v2 @@ -90,3 +90,24 @@ jobs: - name: Run simple code run: python -c 'import math; print(math.factorial(5))' + + setup-pypy-legacy: + name: Setup PyPy ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest, ubuntu-16.04, ubuntu-18.04, ubuntu-20.04] + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: setup-python pypy3 + uses: ./ + with: + python-version: 'pypy3' + + - name: setup-python pypy2 + uses: ./ + with: + python-version: 'pypy2' diff --git a/README.md b/README.md index 36d2f7f43..461ee2ea6 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,18 @@ This action sets up a Python environment for use in actions by: -- optionally installing and adding to PATH a version of Python that is already installed in the tools cache -- downloading, installing and adding to PATH an available version of Python from GitHub Releases ([actions/python-versions](https://github.com/actions/python-versions/releases)) if a specific version is not available in the tools cache -- failing if a specific version of Python is not preinstalled or available for download -- registering problem matchers for error output +- optionally installing and adding to PATH a version of Python that is already installed in the tools cache. +- downloading, installing and adding to PATH an available version of Python from GitHub Releases ([actions/python-versions](https://github.com/actions/python-versions/releases)) if a specific version is not available in the tools cache. +- failing if a specific version of Python is not preinstalled or available for download. +- registering problem matchers for error output. # What's new -- Ability to download, install and set up Python packages from `actions/python-versions` that do not come preinstalled on runners - - Allows for pinning to a specific patch version of Python without the worry of it ever being removed or changed -- Automatic setup and download of Python packages if using a self-hosted runner -- Support for pre-release versions of Python +- Ability to download, install and set up Python packages from `actions/python-versions` that do not come preinstalled on runners. + - Allows for pinning to a specific patch version of Python without the worry of it ever being removed or changed. +- Automatic setup and download of Python packages if using a self-hosted runner. +- Support for pre-release versions of Python. +- Support for installing any version of PyPy on-flight # Usage @@ -40,7 +41,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '2.x', '3.x', 'pypy2', 'pypy3' ] + python-version: [ '2.x', '3.x', 'pypy-2.7', 'pypy-3.6', 'pypy-3.7' ] name: Python ${{ matrix.python-version }} sample steps: - uses: actions/checkout@v2 @@ -60,12 +61,12 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [2.7, 3.6, 3.7, 3.8, pypy2, pypy3] + python-version: ['2.7', '3.6', '3.7', '3.8', 'pypy-2.7', 'pypy-3.6'] exclude: - os: macos-latest - python-version: 3.8 + python-version: '3.8' - os: windows-latest - python-version: 3.6 + python-version: '3.6' steps: - uses: actions/checkout@v2 - name: Set up Python @@ -84,14 +85,13 @@ jobs: strategy: matrix: # in this example, there is a newer version already installed, 3.7.7, so the older version will be downloaded - python-version: [3.5, 3.6, 3.7.4, 3.8] + python-version: ['3.5', '3.6', '3.7.4', '3.8'] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - run: python my_script.py - ``` Download and set up an accurate pre-release version of Python: @@ -114,6 +114,27 @@ steps: - run: python my_script.py ``` +Download and set up PyPy: + +```yaml +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - 'pypy-3.6' # the latest available version of PyPy that supports Python 3.6 + - 'pypy-3.7' # the latest available version of PyPy that supports Python 3.7 + - 'pypy-3.7-v7.3.3' # Python 3.7 and PyPy 7.3.3 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - run: python my_script.py +``` +More details on PyPy syntax and examples of using preview / nightly versions of PyPy can be found in the [Available versions of PyPy](#available-versions-of-pypy) section. + # Getting started with Python + Actions Check out our detailed guide on using [Python with GitHub Actions](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/using-python-with-github-actions). @@ -122,15 +143,29 @@ Check out our detailed guide on using [Python with GitHub Actions](https://help. `setup-python` is able to configure Python from two sources: -- Preinstalled versions of Python in the tools cache on GitHub-hosted runners +- Preinstalled versions of Python in the tools cache on GitHub-hosted runners. - For detailed information regarding the available versions of Python that are installed see [Supported software](https://docs.github.com/en/actions/reference/specifications-for-github-hosted-runners#supported-software). - For every minor version of Python, expect only the latest patch to be preinstalled. - If `3.8.1` is installed for example, and `3.8.2` is released, expect `3.8.1` to be removed and replaced by `3.8.2` in the tools cache. - If the exact patch version doesn't matter to you, specifying just the major and minor version will get you the latest preinstalled patch version. In the previous example, the version spec `3.8` will use the `3.8.2` Python version found in the cache. -- Downloadable Python versions from GitHub Releases ([actions/python-versions](https://github.com/actions/python-versions/releases)) +- Downloadable Python versions from GitHub Releases ([actions/python-versions](https://github.com/actions/python-versions/releases)). - All available versions are listed in the [version-manifest.json](https://github.com/actions/python-versions/blob/main/versions-manifest.json) file. - If there is a specific version of Python that is not available, you can open an issue here + # Available versions of PyPy + + `setup-python` is able to configure PyPy from two sources: + +- Preinstalled versions of PyPy in the tools cache on GitHub-hosted runners + - For detailed information regarding the available versions of PyPy that are installed see [Supported software](https://docs.github.com/en/actions/reference/specifications-for-github-hosted-runners#supported-software). + - For the latest PyPy release, all versions of Python are cached. + - Cache is updated with a 1-2 week delay. If you specify the PyPy version as `pypy-3.6`, the cached version will be used although a newer version is available. If you need to start using the recently released version right after release, you should specify the exact PyPy version using `pypy-3.6-v7.3.3`. + +- Downloadable PyPy versions from the [official PyPy site](https://downloads.python.org/pypy/). + - All available versions that we can download are listed in [versions.json](https://downloads.python.org/pypy/versions.json) file. + - PyPy < 7.3.3 are not available to install on-flight. + - If some versions are not available, you can open an issue in https://foss.heptapod.net/pypy/pypy/ + # Hosted Tool Cache GitHub hosted runners have a tools cache that comes with a few versions of Python + PyPy already installed. This tools cache helps speed up runs and tool setup by not requiring any new downloads. There is an environment variable called `RUNNER_TOOL_CACHE` on each runner that describes the location of this tools cache and there is where you will find Python and PyPy installed. `setup-python` works by taking a specific version of Python or PyPy in this tools cache and adding it to PATH. @@ -156,6 +191,20 @@ You should specify only a major and minor version if you are okay with the most - The patch version that will be preinstalled, will generally be the latest and every time there is a new patch released, the older version that is preinstalled will be replaced. - Using the most recent patch version will result in a very quick setup since no downloads will be required since a locally installed version Python on the runner will be used. +# Specifying a PyPy version +The version of PyPy should be specified in the format `pypy-[-v]`. +The `` parameter is optional and can be skipped. The latest version will be used in this case. + +``` +pypy-3.6 # the latest available version of PyPy that supports Python 3.6 +pypy-3.7 # the latest available version of PyPy that supports Python 3.7 +pypy-2.7 # the latest available version of PyPy that supports Python 2.7 +pypy-3.7-v7.3.3 # Python 3.7 and PyPy 7.3.3 +pypy-3.7-v7.x # Python 3.7 and the latest available PyPy 7.x +pypy-3.7-v7.3.3rc1 # Python 3.7 and preview version of PyPy +pypy-3.7-nightly # Python 3.7 and nightly PyPy +``` + # Using `setup-python` with a self hosted runner Python distributions are only available for the same [environments](https://github.com/actions/virtual-environments#available-environments) that GitHub Actions hosted environments are available for. If you are using an unsupported version of Ubuntu such as `19.04` or another Linux distribution such as Fedora, `setup-python` will not work. If you have a supported self-hosted runner and you would like to use `setup-python`, there are a few extra things you need to make sure are set up so that new versions of Python can be downloaded and configured on your runner. @@ -174,22 +223,22 @@ If you are experiencing problems while configuring Python on your self-hosted ru - The Python packages that are downloaded from `actions/python-versions` are originally compiled from source in `/opt/hostedtoolcache/` with the [--enable-shared](https://github.com/actions/python-versions/blob/94f04ae6806c6633c82db94c6406a16e17decd5c/builders/ubuntu-python-builder.psm1#L35) flag, which makes them non-relocatable. - Create an environment variable called `AGENT_TOOLSDIRECTORY` and set it to `/opt/hostedtoolcache`. This controls where the runner downloads and installs tools. - - In the same shell that your runner is using, type `export AGENT_TOOLSDIRECTORY=/opt/hostedtoolcache` + - In the same shell that your runner is using, type `export AGENT_TOOLSDIRECTORY=/opt/hostedtoolcache`. - A more permanent way of setting the environment variable is to create a `.env` file in the same directory as your runner and to add `AGENT_TOOLSDIRECTORY=/opt/hostedtoolcache`. This ensures the variable is always set if your runner is configured as a service. - Create a directory called `hostedtoolcache` inside `/opt`. - The user starting the runner must have write permission to the `/opt/hostedtoolcache` directory. It is not possible to start the Linux runner with `sudo` and the `/opt` directory usually requires root privileges to write to. Check the current user and group that the runner belongs to by typing `ls -l` inside the runners root directory. - The runner can be granted write access to the `/opt/hostedtoolcache` directory using a few techniques: - - The user starting the runner is the owner, and the owner has write permission - - The user starting the runner is in the owning group, and the owning group has write permission - - All users have write permission -- One quick way to grant access is to change the user and group of `/opt/hostedtoolcache` to be the same as the runners using `chown` - - `sudo chown runner-user:runner-group opt/hostedtoolcache/` + - The user starting the runner is the owner, and the owner has write permission. + - The user starting the runner is in the owning group, and the owning group has write permission. + - All users have write permission. +- One quick way to grant access is to change the user and group of `/opt/hostedtoolcache` to be the same as the runners using `chown`. + - `sudo chown runner-user:runner-group opt/hostedtoolcache/`. - If your runner is configured as a service and you run into problems, make sure the user that the service is running as is correct. For more information, you can [check the status of your self-hosted runner](https://help.github.com/en/actions/hosting-your-own-runners/configuring-the-self-hosted-runner-application-as-a-service#checking-the-status-of-the-service). ### Mac - The same setup that applies to `Linux` also applies to `Mac`, just with a different tools cache directory. -- Create a directory called `/Users/runner/hostedtoolcache` +- Create a directory called `/Users/runner/hostedtoolcache`. - Set the `AGENT_TOOLSDIRECTORY` environment variable to `/Users/runner/hostedtoolcache`. - Change the permissions of `/Users/runner/hostedtoolcache` so that the runner has write access. @@ -200,8 +249,8 @@ If you are experiencing problems while configuring Python on your self-hosted ru # License -The scripts and documentation in this project are released under the [MIT License](LICENSE) +The scripts and documentation in this project are released under the [MIT License](LICENSE). # Contributions -Contributions are welcome! See our [Contributor's Guide](docs/contributors.md) +Contributions are welcome! See our [Contributor's Guide](docs/contributors.md). diff --git a/__tests__/data/pypy.json b/__tests__/data/pypy.json new file mode 100644 index 000000000..c8889a3b5 --- /dev/null +++ b/__tests__/data/pypy.json @@ -0,0 +1,533 @@ +[ + { + "pypy_version": "7.3.3", + "python_version": "3.6.12", + "stable": true, + "latest_pypy": true, + "date": "2020-11-21", + "files": [ + { + "filename": "pypy3.6-v7.3.3-aarch64.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3-aarch64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.3-linux32.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3-linux32.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.3-linux64.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3-linux64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.3-darwin64.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3-darwin64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.3-win32.zip", + "arch": "x86", + "platform": "win32", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3-win32.zip" + }, + { + "filename": "pypy3.6-v7.3.3-s390x.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3-s390x.tar.bz2" + } + ] + }, + { + "pypy_version": "7.3.3rc1", + "python_version": "3.6.12", + "stable": false, + "latest_pypy": false, + "date": "2020-11-11", + "files": [ + { + "filename": "pypy3.6-v7.3.3rc1-aarch64.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3rc1-aarch64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.3-linux32rc1.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3rc1-linux32.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.3rc1-linux64.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3rc1-linux64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.3rc1-osx64.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3rc1-osx64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.3-win32rc1.zip", + "arch": "x86", + "platform": "win32", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3rc1-win32.zip" + }, + { + "filename": "pypy3.6-v7.3.3rc1-s390x.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.3rc1-s390x.tar.bz2" + } + ] + }, + { + "pypy_version": "7.3.4rc1", + "python_version": "2.7.18", + "stable": false, + "latest_pypy": false, + "date": "2021-03-19", + "files": [ + { + "filename": "pypy2.7-v7.3.4rc1-aarch64.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "https://test.downloads.python.org/pypy/pypy2.7-v7.3.4rc1-aarch64.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.4rc1-linux32.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "https://test.downloads.python.org/pypy/pypy2.7-v7.3.4rc1-linux32.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.4rc1-linux64.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "https://test.downloads.python.org/pypy/pypy2.7-v7.3.4rc1-linux64.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.4rc1-osx64.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "https://test.downloads.python.org/pypy/pypy2.7-v7.3.4rc1-osx64.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.4rc1-win64.zip", + "arch": "x64", + "platform": "win64", + "download_url": "https://test.downloads.python.org/pypy/pypy2.7-v7.3.4rc1-win64.zip" + } + ] + }, + { + "pypy_version": "7.3.3rc2", + "python_version": "3.7.7", + "stable": false, + "latest_pypy": false, + "date": "2020-11-11", + "files": [ + { + "filename": "test.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "test.tar.bz2" + }, + { + "filename": "test.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "test.tar.bz2" + }, + { + "filename": "test.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "test.tar.bz2" + }, + { + "filename": "test.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "test.tar.bz2" + }, + { + "filename": "test.zip", + "arch": "x86", + "platform": "win32", + "download_url": "test.zip" + }, + { + "filename": "test.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "test.tar.bz2" + } + ] + }, + { + "pypy_version": "7.3.3", + "python_version": "3.7.9", + "stable": true, + "latest_pypy": true, + "date": "2020-11-21", + "files": [ + { + "filename": "pypy3.7-v7.3.3-aarch64.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.3-aarch64.tar.bz2" + }, + { + "filename": "pypy3.7-v7.3.3-linux32.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.3-linux32.tar.bz2" + }, + { + "filename": "pypy3.7-v7.3.3-linux64.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.3-linux64.tar.bz2" + }, + { + "filename": "pypy3.7-v7.3.3-osx64.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.3-osx64.tar.bz2" + }, + { + "filename": "pypy3.7-v7.3.3-win32.zip", + "arch": "x86", + "platform": "win32", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.3-win32.zip" + }, + { + "filename": "pypy3.7-v7.3.3-s390x.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.3-s390x.tar.bz2" + } + ] + }, + { + "pypy_version": "7.3.3", + "python_version": "2.7.18", + "stable": true, + "latest_pypy": true, + "date": "2020-11-21", + "files": [ + { + "filename": "pypy2.7-v7.3.3-aarch64.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.3-aarch64.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.3-linux32.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.3-linux32.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.3-linux64.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.3-linux64.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.3-osx64.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.3-osx64.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.3-win32.zip", + "arch": "x86", + "platform": "win32", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.3-win32.zip" + }, + { + "filename": "pypy2.7-v7.3.3-s390x.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.3-s390x.tar.bz2" + } + ] + }, + { + "pypy_version": "7.3.2", + "python_version": "3.6.9", + "stable": true, + "latest_pypy": true, + "date": "2020-09-25", + "files": [ + { + "filename": "pypy3.6-v7.3.2-aarch64.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.2-aarch64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.2-linux32.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.2-linux32.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.2-linux64.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.2-linux64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.2-osx64.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.2-osx64.tar.bz2" + }, + { + "filename": "pypy3.6-v7.3.2-win32.zip", + "arch": "x86", + "platform": "win32", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.2-win32.zip" + }, + { + "filename": "pypy3.6-v7.3.2-s390x.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.6-v7.3.2-s390x.tar.bz2" + } + ] + }, + { + "pypy_version": "7.3.2", + "python_version": "3.7.9", + "stable": true, + "latest_pypy": false, + "date": "2020-09-25", + "files": [ + { + "filename": "pypy3.7-v7.3.2-aarch64.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.2-aarch64.tar.bz2" + }, + { + "filename": "pypy3.7-v7.3.2-linux32.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.2-linux32.tar.bz2" + }, + { + "filename": "pypy3.7-v7.3.2-linux64.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.2-linux64.tar.bz2" + }, + { + "filename": "pypy3.7-v7.3.2-osx64.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.2-osx64.tar.bz2" + }, + { + "filename": "pypy3.7-v7.3.2-win32.zip", + "arch": "x86", + "platform": "win32", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.2-win32.zip" + }, + { + "filename": "pypy3.7-v7.3.2-s390x.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy3.7-v7.3.2-s390x.tar.bz2" + } + ] + }, + { + "pypy_version": "7.3.2", + "python_version": "2.7.13", + "stable": true, + "latest_pypy": true, + "date": "2020-09-25", + "files": [ + { + "filename": "pypy2.7-v7.3.2-aarch64.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.2-aarch64.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.2-linux32.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.2-linux32.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.2-linux64.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.2-linux64.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.2-osx64.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.2-osx64.tar.bz2" + }, + { + "filename": "pypy2.7-v7.3.2-win32.zip", + "arch": "x86", + "platform": "win32", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.2-win32.zip" + }, + { + "filename": "pypy2.7-v7.3.2-s390x.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "https://test.download.python.org/pypy/pypy2.7-v7.3.2-s390x.tar.bz2" + } + ] + }, + { + "pypy_version": "nightly", + "python_version": "2.7", + "stable": false, + "latest_pypy": false, + "files": [ + { + "filename": "filename.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.zip", + "arch": "x86", + "platform": "win32", + "download_url": "http://nightlyBuilds.org/filename.zip" + }, + { + "filename": "filename.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + } + ] + }, + { + "pypy_version": "nightly", + "python_version": "3.7", + "stable": false, + "latest_pypy": false, + "files": [ + { + "filename": "filename.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.zip", + "arch": "x86", + "platform": "win32", + "download_url": "http://nightlyBuilds.org/filename.zip" + }, + { + "filename": "filename.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + } + ] + }, + { + "pypy_version": "nightly", + "python_version": "3.6", + "stable": false, + "latest_pypy": false, + "files": [ + { + "filename": "filename.tar.bz2", + "arch": "aarch64", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.tar.bz2", + "arch": "i686", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.tar.bz2", + "arch": "x64", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.tar.bz2", + "arch": "x64", + "platform": "darwin", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + }, + { + "filename": "filename.zip", + "arch": "x86", + "platform": "win32", + "download_url": "http://nightlyBuilds.org/filename.zip" + }, + { + "filename": "filename.tar.bz2", + "arch": "s390x", + "platform": "linux", + "download_url": "http://nightlyBuilds.org/filename.tar.bz2" + } + ] + } +] \ No newline at end of file diff --git a/__tests__/find-pypy.test.ts b/__tests__/find-pypy.test.ts new file mode 100644 index 000000000..ddf7ebcf4 --- /dev/null +++ b/__tests__/find-pypy.test.ts @@ -0,0 +1,237 @@ +import fs from 'fs'; + +import * as utils from '../src/utils'; +import {HttpClient} from '@actions/http-client'; +import * as ifm from '@actions/http-client/interfaces'; +import * as tc from '@actions/tool-cache'; +import * as exec from '@actions/exec'; + +import * as path from 'path'; +import * as semver from 'semver'; + +import * as finder from '../src/find-pypy'; +import { + IPyPyManifestRelease, + IS_WINDOWS, + validateVersion, + getPyPyVersionFromPath +} from '../src/utils'; + +const manifestData = require('./data/pypy.json'); + +let architecture: string; + +if (IS_WINDOWS) { + architecture = 'x86'; +} else { + architecture = 'x64'; +} + +const toolDir = path.join(__dirname, 'runner', 'tools'); +const tempDir = path.join(__dirname, 'runner', 'temp'); + +describe('parsePyPyVersion', () => { + it.each([ + ['pypy-3.6-v7.3.3', {pythonVersion: '3.6', pypyVersion: 'v7.3.3'}], + ['pypy-3.6-v7.3.x', {pythonVersion: '3.6', pypyVersion: 'v7.3.x'}], + ['pypy-3.6-v7.x', {pythonVersion: '3.6', pypyVersion: 'v7.x'}], + ['pypy-3.6', {pythonVersion: '3.6', pypyVersion: 'x'}], + ['pypy-3.6-nightly', {pythonVersion: '3.6', pypyVersion: 'nightly'}], + ['pypy-3.6-v7.3.3rc1', {pythonVersion: '3.6', pypyVersion: 'v7.3.3-rc.1'}] + ])('%s -> %s', (input, expected) => { + expect(finder.parsePyPyVersion(input)).toEqual(expected); + }); + + it('throw on invalid input', () => { + expect(() => finder.parsePyPyVersion('pypy-')).toThrowError( + "Invalid 'version' property for PyPy. PyPy version should be specified as 'pypy-'. See README for examples and documentation." + ); + }); +}); + +describe('getPyPyVersionFromPath', () => { + it('/fake/toolcache/PyPy/3.6.5/x64 -> 3.6.5', () => { + expect(getPyPyVersionFromPath('/fake/toolcache/PyPy/3.6.5/x64')).toEqual( + '3.6.5' + ); + }); +}); + +describe('findPyPyToolCache', () => { + const actualPythonVersion = '3.6.17'; + const actualPyPyVersion = '7.5.4'; + const pypyPath = path.join('PyPy', actualPythonVersion, architecture); + let tcFind: jest.SpyInstance; + let spyReadExactPyPyVersion: jest.SpyInstance; + + beforeEach(() => { + tcFind = jest.spyOn(tc, 'find'); + tcFind.mockImplementation((toolname: string, pythonVersion: string) => { + const semverVersion = new semver.Range(pythonVersion); + return semver.satisfies(actualPythonVersion, semverVersion) + ? pypyPath + : ''; + }); + + spyReadExactPyPyVersion = jest.spyOn(utils, 'readExactPyPyVersionFile'); + spyReadExactPyPyVersion.mockImplementation(() => actualPyPyVersion); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('PyPy exists on the path and versions are satisfied', () => { + expect(finder.findPyPyToolCache('3.6.17', 'v7.5.4', architecture)).toEqual({ + installDir: pypyPath, + resolvedPythonVersion: actualPythonVersion, + resolvedPyPyVersion: actualPyPyVersion + }); + }); + + it('PyPy exists on the path and versions are satisfied with semver', () => { + expect(finder.findPyPyToolCache('3.6', 'v7.5.x', architecture)).toEqual({ + installDir: pypyPath, + resolvedPythonVersion: actualPythonVersion, + resolvedPyPyVersion: actualPyPyVersion + }); + }); + + it("PyPy exists on the path, but Python version doesn't match", () => { + expect(finder.findPyPyToolCache('3.7', 'v7.5.4', architecture)).toEqual({ + installDir: '', + resolvedPythonVersion: '', + resolvedPyPyVersion: '' + }); + }); + + it("PyPy exists on the path, but PyPy version doesn't match", () => { + expect(finder.findPyPyToolCache('3.6', 'v7.5.1', architecture)).toEqual({ + installDir: null, + resolvedPythonVersion: '', + resolvedPyPyVersion: '' + }); + }); +}); + +describe('findPyPyVersion', () => { + let tcFind: jest.SpyInstance; + let spyExtractZip: jest.SpyInstance; + let spyExtractTar: jest.SpyInstance; + let spyHttpClient: jest.SpyInstance; + let spyExistsSync: jest.SpyInstance; + let spyExec: jest.SpyInstance; + let spySymlinkSync: jest.SpyInstance; + let spyDownloadTool: jest.SpyInstance; + let spyReadExactPyPyVersion: jest.SpyInstance; + let spyFsReadDir: jest.SpyInstance; + let spyWriteExactPyPyVersionFile: jest.SpyInstance; + let spyCacheDir: jest.SpyInstance; + let spyChmodSync: jest.SpyInstance; + + beforeEach(() => { + tcFind = jest.spyOn(tc, 'find'); + tcFind.mockImplementation((tool: string, version: string) => { + const semverRange = new semver.Range(version); + let pypyPath = ''; + if (semver.satisfies('3.6.12', semverRange)) { + pypyPath = path.join(toolDir, 'PyPy', '3.6.12', architecture); + } + return pypyPath; + }); + + spyWriteExactPyPyVersionFile = jest.spyOn( + utils, + 'writeExactPyPyVersionFile' + ); + spyWriteExactPyPyVersionFile.mockImplementation(() => null); + + spyReadExactPyPyVersion = jest.spyOn(utils, 'readExactPyPyVersionFile'); + spyReadExactPyPyVersion.mockImplementation(() => '7.3.3'); + + spyDownloadTool = jest.spyOn(tc, 'downloadTool'); + spyDownloadTool.mockImplementation(() => path.join(tempDir, 'PyPy')); + + spyExtractZip = jest.spyOn(tc, 'extractZip'); + spyExtractZip.mockImplementation(() => tempDir); + + spyExtractTar = jest.spyOn(tc, 'extractTar'); + spyExtractTar.mockImplementation(() => tempDir); + + spyFsReadDir = jest.spyOn(fs, 'readdirSync'); + spyFsReadDir.mockImplementation((directory: string) => ['PyPyTest']); + + spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); + spyHttpClient.mockImplementation( + async (): Promise> => { + const result = JSON.stringify(manifestData); + return { + statusCode: 200, + headers: {}, + result: JSON.parse(result) as IPyPyManifestRelease[] + }; + } + ); + + spyExec = jest.spyOn(exec, 'exec'); + spyExec.mockImplementation(() => undefined); + + spySymlinkSync = jest.spyOn(fs, 'symlinkSync'); + spySymlinkSync.mockImplementation(() => undefined); + + spyExistsSync = jest.spyOn(fs, 'existsSync'); + spyExistsSync.mockReturnValue(true); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('found PyPy in toolcache', async () => { + await expect( + finder.findPyPyVersion('pypy-3.6-v7.3.x', architecture) + ).resolves.toEqual({ + resolvedPythonVersion: '3.6.12', + resolvedPyPyVersion: '7.3.3' + }); + }); + + it('throw on invalid input format', async () => { + await expect( + finder.findPyPyVersion('pypy3.7-v7.3.x', architecture) + ).rejects.toThrow(); + }); + + it('throw on invalid input format pypy3.7-7.3.x', async () => { + await expect( + finder.findPyPyVersion('pypy3.7-v7.3.x', architecture) + ).rejects.toThrow(); + }); + + it('found and install successfully', async () => { + spyCacheDir = jest.spyOn(tc, 'cacheDir'); + spyCacheDir.mockImplementation(() => + path.join(toolDir, 'PyPy', '3.7.7', architecture) + ); + spyChmodSync = jest.spyOn(fs, 'chmodSync'); + spyChmodSync.mockImplementation(() => undefined); + await expect( + finder.findPyPyVersion('pypy-3.7-v7.3.x', architecture) + ).resolves.toEqual({ + resolvedPythonVersion: '3.7.9', + resolvedPyPyVersion: '7.3.3' + }); + }); + + it('throw if release is not found', async () => { + await expect( + finder.findPyPyVersion('pypy-3.7-v7.5.x', architecture) + ).rejects.toThrowError( + `PyPy version 3.7 (v7.5.x) with arch ${architecture} not found` + ); + }); +}); diff --git a/__tests__/install-pypy.test.ts b/__tests__/install-pypy.test.ts new file mode 100644 index 000000000..cffc90e8f --- /dev/null +++ b/__tests__/install-pypy.test.ts @@ -0,0 +1,230 @@ +import fs from 'fs'; + +import {HttpClient} from '@actions/http-client'; +import * as ifm from '@actions/http-client/interfaces'; +import * as tc from '@actions/tool-cache'; +import * as exec from '@actions/exec'; +import * as path from 'path'; + +import * as installer from '../src/install-pypy'; +import { + IPyPyManifestRelease, + IPyPyManifestAsset, + IS_WINDOWS +} from '../src/utils'; + +const manifestData = require('./data/pypy.json'); + +let architecture: string; +if (IS_WINDOWS) { + architecture = 'x86'; +} else { + architecture = 'x64'; +} + +const toolDir = path.join(__dirname, 'runner', 'tools'); +const tempDir = path.join(__dirname, 'runner', 'temp'); + +describe('pypyVersionToSemantic', () => { + it.each([ + ['7.3.3rc1', '7.3.3-rc.1'], + ['7.3.3', '7.3.3'], + ['7.3.x', '7.3.x'], + ['7.x', '7.x'], + ['nightly', 'nightly'] + ])('%s -> %s', (input, expected) => { + expect(installer.pypyVersionToSemantic(input)).toEqual(expected); + }); +}); + +describe('findRelease', () => { + const result = JSON.stringify(manifestData); + const releases = JSON.parse(result) as IPyPyManifestRelease[]; + const extension = IS_WINDOWS ? '.zip' : '.tar.bz2'; + const extensionName = IS_WINDOWS + ? `${process.platform}${extension}` + : `${process.platform}64${extension}`; + const files: IPyPyManifestAsset = { + filename: `pypy3.6-v7.3.3-${extensionName}`, + arch: architecture, + platform: process.platform, + download_url: `https://test.download.python.org/pypy/pypy3.6-v7.3.3-${extensionName}` + }; + + it("Python version is found, but PyPy version doesn't match", () => { + const pythonVersion = '3.6'; + const pypyVersion = '7.3.7'; + expect( + installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + ).toEqual(null); + }); + + it('Python version is found and PyPy version matches', () => { + const pythonVersion = '3.6'; + const pypyVersion = '7.3.3'; + expect( + installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + ).toEqual({ + foundAsset: files, + resolvedPythonVersion: '3.6.12', + resolvedPyPyVersion: pypyVersion + }); + }); + + it('Python version is found in toolcache and PyPy version matches semver', () => { + const pythonVersion = '3.6'; + const pypyVersion = '7.x'; + expect( + installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + ).toEqual({ + foundAsset: files, + resolvedPythonVersion: '3.6.12', + resolvedPyPyVersion: '7.3.3' + }); + }); + + it('Python and preview version of PyPy are found', () => { + const pythonVersion = '3.7'; + const pypyVersion = installer.pypyVersionToSemantic('7.3.3rc2'); + expect( + installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + ).toEqual({ + foundAsset: { + filename: `test${extension}`, + arch: architecture, + platform: process.platform, + download_url: `test${extension}` + }, + resolvedPythonVersion: '3.7.7', + resolvedPyPyVersion: '7.3.3rc2' + }); + }); + + it('Python version with latest PyPy is found', () => { + const pythonVersion = '3.6'; + const pypyVersion = 'x'; + expect( + installer.findRelease(releases, pythonVersion, pypyVersion, architecture) + ).toEqual({ + foundAsset: files, + resolvedPythonVersion: '3.6.12', + resolvedPyPyVersion: '7.3.3' + }); + }); + + 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) + ).toEqual({ + foundAsset: { + filename: filename, + arch: architecture, + platform: process.platform, + download_url: `http://nightlyBuilds.org/${filename}` + }, + resolvedPythonVersion: '3.6', + resolvedPyPyVersion: pypyVersion + }); + }); +}); + +describe('installPyPy', () => { + let tcFind: jest.SpyInstance; + let spyExtractZip: jest.SpyInstance; + let spyExtractTar: jest.SpyInstance; + let spyFsReadDir: jest.SpyInstance; + let spyFsWriteFile: jest.SpyInstance; + let spyHttpClient: jest.SpyInstance; + let spyExistsSync: jest.SpyInstance; + let spyExec: jest.SpyInstance; + let spySymlinkSync: jest.SpyInstance; + let spyDownloadTool: jest.SpyInstance; + let spyCacheDir: jest.SpyInstance; + let spyChmodSync: jest.SpyInstance; + + beforeEach(() => { + tcFind = jest.spyOn(tc, 'find'); + tcFind.mockImplementation(() => path.join('PyPy', '3.6.12', architecture)); + + spyDownloadTool = jest.spyOn(tc, 'downloadTool'); + spyDownloadTool.mockImplementation(() => path.join(tempDir, 'PyPy')); + + spyExtractZip = jest.spyOn(tc, 'extractZip'); + spyExtractZip.mockImplementation(() => tempDir); + + spyExtractTar = jest.spyOn(tc, 'extractTar'); + spyExtractTar.mockImplementation(() => tempDir); + + spyFsReadDir = jest.spyOn(fs, 'readdirSync'); + spyFsReadDir.mockImplementation(() => ['PyPyTest']); + + spyFsWriteFile = jest.spyOn(fs, 'writeFileSync'); + spyFsWriteFile.mockImplementation(() => undefined); + + spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); + spyHttpClient.mockImplementation( + async (): Promise> => { + const result = JSON.stringify(manifestData); + return { + statusCode: 200, + headers: {}, + result: JSON.parse(result) as IPyPyManifestRelease[] + }; + } + ); + + spyExec = jest.spyOn(exec, 'exec'); + spyExec.mockImplementation(() => undefined); + + spySymlinkSync = jest.spyOn(fs, 'symlinkSync'); + spySymlinkSync.mockImplementation(() => undefined); + + spyExistsSync = jest.spyOn(fs, 'existsSync'); + spyExistsSync.mockImplementation(() => false); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('throw if release is not found', async () => { + await expect( + installer.installPyPy('7.3.3', '3.6.17', architecture) + ).rejects.toThrowError( + `PyPy version 3.6.17 (7.3.3) with arch ${architecture} not found` + ); + + expect(spyHttpClient).toHaveBeenCalled(); + expect(spyDownloadTool).not.toHaveBeenCalled(); + expect(spyExec).not.toHaveBeenCalled(); + }); + + it('found and install PyPy', 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.3.x', '3.6.12', architecture) + ).resolves.toEqual({ + installDir: path.join(toolDir, 'PyPy', '3.6.12', architecture), + resolvedPythonVersion: '3.6.12', + resolvedPyPyVersion: '7.3.3' + }); + + expect(spyHttpClient).toHaveBeenCalled(); + expect(spyDownloadTool).toHaveBeenCalled(); + expect(spyExistsSync).toHaveBeenCalled(); + expect(spyCacheDir).toHaveBeenCalled(); + expect(spyExec).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts new file mode 100644 index 000000000..9463849f9 --- /dev/null +++ b/__tests__/utils.test.ts @@ -0,0 +1,34 @@ +import { + validateVersion, + validatePythonVersionFormatForPyPy +} from '../src/utils'; + +describe('validatePythonVersionFormatForPyPy', () => { + it.each([ + ['3.6', true], + ['3.7', true], + ['3.6.x', false], + ['3.7.x', false], + ['3.x', false], + ['3', false] + ])('%s -> %s', (input, expected) => { + expect(validatePythonVersionFormatForPyPy(input)).toEqual(expected); + }); +}); + +describe('validateVersion', () => { + it.each([ + ['v7.3.3', true], + ['v7.3.x', true], + ['v7.x', true], + ['x', true], + ['v7.3.3-rc.1', true], + ['nightly', true], + ['v7.3.b', false], + ['3.6', true], + ['3.b', false], + ['3', true] + ])('%s -> %s', (input, expected) => { + expect(validateVersion(input)).toEqual(expected); + }); +}); diff --git a/dist/index.js b/dist/index.js index 7ba2ab55f..16cab2276 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1072,6 +1072,117 @@ function _readLinuxVersionFile() { exports._readLinuxVersionFile = _readLinuxVersionFile; //# sourceMappingURL=manifest.js.map +/***/ }), + +/***/ 50: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const path = __importStar(__webpack_require__(622)); +const pypyInstall = __importStar(__webpack_require__(369)); +const utils_1 = __webpack_require__(163); +const semver = __importStar(__webpack_require__(876)); +const core = __importStar(__webpack_require__(470)); +const tc = __importStar(__webpack_require__(533)); +function findPyPyVersion(versionSpec, architecture) { + return __awaiter(this, void 0, void 0, function* () { + let resolvedPyPyVersion = ''; + let resolvedPythonVersion = ''; + let installDir; + const pypyVersionSpec = parsePyPyVersion(versionSpec); + ({ installDir, resolvedPythonVersion, resolvedPyPyVersion } = findPyPyToolCache(pypyVersionSpec.pythonVersion, pypyVersionSpec.pypyVersion, architecture)); + if (!installDir) { + ({ + installDir, + resolvedPythonVersion, + resolvedPyPyVersion + } = yield pypyInstall.installPyPy(pypyVersionSpec.pypyVersion, pypyVersionSpec.pythonVersion, architecture)); + } + const pipDir = utils_1.IS_WINDOWS ? 'Scripts' : 'bin'; + const _binDir = path.join(installDir, pipDir); + const pythonLocation = pypyInstall.getPyPyBinaryPath(installDir); + core.exportVariable('pythonLocation', pythonLocation); + core.addPath(pythonLocation); + core.addPath(_binDir); + return { resolvedPyPyVersion, resolvedPythonVersion }; + }); +} +exports.findPyPyVersion = findPyPyVersion; +function findPyPyToolCache(pythonVersion, pypyVersion, architecture) { + let resolvedPyPyVersion = ''; + let resolvedPythonVersion = ''; + let installDir = utils_1.IS_WINDOWS + ? findPyPyInstallDirForWindows(pythonVersion) + : tc.find('PyPy', pythonVersion, architecture); + if (installDir) { + // 'tc.find' finds tool based on Python version but we also need to check + // whether PyPy version satisfies requested version. + resolvedPythonVersion = utils_1.getPyPyVersionFromPath(installDir); + resolvedPyPyVersion = utils_1.readExactPyPyVersionFile(installDir); + const isPyPyVersionSatisfies = semver.satisfies(resolvedPyPyVersion, pypyVersion); + if (!isPyPyVersionSatisfies) { + installDir = null; + resolvedPyPyVersion = ''; + resolvedPythonVersion = ''; + } + } + if (!installDir) { + core.info(`PyPy version ${pythonVersion} (${pypyVersion}) was not found in the local cache`); + } + return { installDir, resolvedPythonVersion, resolvedPyPyVersion }; +} +exports.findPyPyToolCache = findPyPyToolCache; +function parsePyPyVersion(versionSpec) { + const versions = versionSpec.split('-').filter(item => !!item); + if (versions.length < 2 || versions[0] != 'pypy') { + throw new Error("Invalid 'version' property for PyPy. PyPy version should be specified as 'pypy-'. See README for examples and documentation."); + } + const pythonVersion = versions[1]; + let pypyVersion; + if (versions.length > 2) { + pypyVersion = pypyInstall.pypyVersionToSemantic(versions[2]); + } + else { + pypyVersion = 'x'; + } + if (!utils_1.validateVersion(pythonVersion) || !utils_1.validateVersion(pypyVersion)) { + throw new Error("Invalid 'version' property for PyPy. Both Python version and PyPy versions should satisfy SemVer notation. See README for examples and documentation."); + } + if (!utils_1.validatePythonVersionFormatForPyPy(pythonVersion)) { + throw new Error("Invalid format of Python version for PyPy. Python version should be specified in format 'x.y'. See README for examples and documentation."); + } + return { + pypyVersion: pypyVersion, + pythonVersion: pythonVersion + }; +} +exports.parsePyPyVersion = parsePyPyVersion; +function findPyPyInstallDirForWindows(pythonVersion) { + let installDir = ''; + utils_1.WINDOWS_ARCHS.forEach(architecture => (installDir = installDir || tc.find('PyPy', pythonVersion, architecture))); + return installDir; +} +exports.findPyPyInstallDirForWindows = findPyPyInstallDirForWindows; + + /***/ }), /***/ 65: @@ -2197,6 +2308,94 @@ if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) { exports.debug = debug; // for test +/***/ }), + +/***/ 163: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const fs_1 = __importDefault(__webpack_require__(747)); +const path = __importStar(__webpack_require__(622)); +const semver = __importStar(__webpack_require__(876)); +exports.IS_WINDOWS = process.platform === 'win32'; +exports.IS_LINUX = process.platform === 'linux'; +exports.WINDOWS_ARCHS = ['x86', 'x64']; +exports.WINDOWS_PLATFORMS = ['win32', 'win64']; +const PYPY_VERSION_FILE = 'PYPY_VERSION'; +/** create Symlinks for downloaded PyPy + * It should be executed only for downloaded versions in runtime, because + * toolcache versions have this setup. + */ +function createSymlinkInFolder(folderPath, sourceName, targetName, setExecutable = false) { + const sourcePath = path.join(folderPath, sourceName); + const targetPath = path.join(folderPath, targetName); + if (fs_1.default.existsSync(targetPath)) { + return; + } + fs_1.default.symlinkSync(sourcePath, targetPath); + if (!exports.IS_WINDOWS && setExecutable) { + fs_1.default.chmodSync(targetPath, '755'); + } +} +exports.createSymlinkInFolder = createSymlinkInFolder; +function validateVersion(version) { + return isNightlyKeyword(version) || Boolean(semver.validRange(version)); +} +exports.validateVersion = validateVersion; +function isNightlyKeyword(pypyVersion) { + return pypyVersion === 'nightly'; +} +exports.isNightlyKeyword = isNightlyKeyword; +function getPyPyVersionFromPath(installDir) { + return path.basename(path.dirname(installDir)); +} +exports.getPyPyVersionFromPath = getPyPyVersionFromPath; +/** + * In tool-cache, we put PyPy to '/PyPy//x64' + * There is no easy way to determine what PyPy version is located in specific folder + * 'pypy --version' is not reliable enough since it is not set properly for preview versions + * "7.3.3rc1" is marked as '7.3.3' in 'pypy --version' + * so we put PYPY_VERSION file to PyPy directory when install it to VM and read it when we need to know version + * PYPY_VERSION contains exact version from 'versions.json' + */ +function readExactPyPyVersionFile(installDir) { + let pypyVersion = ''; + let fileVersion = path.join(installDir, PYPY_VERSION_FILE); + if (fs_1.default.existsSync(fileVersion)) { + pypyVersion = fs_1.default.readFileSync(fileVersion).toString(); + } + return pypyVersion; +} +exports.readExactPyPyVersionFile = readExactPyPyVersionFile; +function writeExactPyPyVersionFile(installDir, resolvedPyPyVersion) { + const pypyFilePath = path.join(installDir, PYPY_VERSION_FILE); + fs_1.default.writeFileSync(pypyFilePath, resolvedPyPyVersion); +} +exports.writeExactPyPyVersionFile = writeExactPyPyVersionFile; +/** + * Python version should be specified explicitly like "x.y" (2.7, 3.6, 3.7) + * "3.x" or "3" are not supported + * because it could cause ambiguity when both PyPy version and Python version are not precise + */ +function validatePythonVersionFormatForPyPy(version) { + const re = /^\d+\.\d+$/; + return re.test(version); +} +exports.validatePythonVersionFormatForPyPy = validatePythonVersionFormatForPyPy; + + /***/ }), /***/ 164: @@ -2443,16 +2642,26 @@ var __importStar = (this && this.__importStar) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); const core = __importStar(__webpack_require__(470)); const finder = __importStar(__webpack_require__(927)); +const finderPyPy = __importStar(__webpack_require__(50)); const path = __importStar(__webpack_require__(622)); const os = __importStar(__webpack_require__(87)); +function isPyPyVersion(versionSpec) { + return versionSpec.startsWith('pypy-'); +} function run() { return __awaiter(this, void 0, void 0, function* () { try { let version = core.getInput('python-version'); if (version) { const arch = core.getInput('architecture') || os.arch(); - const installed = yield finder.findPythonVersion(version, arch); - core.info(`Successfully setup ${installed.impl} (${installed.version})`); + if (isPyPyVersion(version)) { + const installed = yield finderPyPy.findPyPyVersion(version, arch); + core.info(`Successfully setup PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})`); + } + else { + const installed = yield finder.findPythonVersion(version, arch); + core.info(`Successfully setup ${installed.impl} (${installed.version})`); + } } const matchersPath = path.join(__dirname, '..', '.github'); core.info(`##[add-matcher]${path.join(matchersPath, 'python.json')}`); @@ -2580,6 +2789,173 @@ module.exports = ltr module.exports = require("assert"); +/***/ }), + +/***/ 369: +/***/ (function(__unusedmodule, exports, __webpack_require__) { + +"use strict"; + +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; + result["default"] = mod; + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const path = __importStar(__webpack_require__(622)); +const core = __importStar(__webpack_require__(470)); +const tc = __importStar(__webpack_require__(533)); +const semver = __importStar(__webpack_require__(876)); +const httpm = __importStar(__webpack_require__(539)); +const exec = __importStar(__webpack_require__(986)); +const fs_1 = __importDefault(__webpack_require__(747)); +const utils_1 = __webpack_require__(163); +function installPyPy(pypyVersion, pythonVersion, architecture) { + return __awaiter(this, void 0, void 0, function* () { + let downloadDir; + const 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); + if (!releaseData || !releaseData.foundAsset) { + throw new Error(`PyPy version ${pythonVersion} (${pypyVersion}) with arch ${architecture} not found`); + } + const { foundAsset, resolvedPythonVersion, resolvedPyPyVersion } = releaseData; + let downloadUrl = `${foundAsset.download_url}`; + core.info(`Downloading PyPy from "${downloadUrl}" ...`); + const pypyPath = yield tc.downloadTool(downloadUrl); + core.info('Extracting downloaded archive...'); + if (utils_1.IS_WINDOWS) { + downloadDir = yield tc.extractZip(pypyPath); + } + else { + downloadDir = yield tc.extractTar(pypyPath, undefined, 'x'); + } + // root folder in archive can have unpredictable name so just take the first folder + // downloadDir is unique folder under TEMP and can't contain any other folders + const archiveName = fs_1.default.readdirSync(downloadDir)[0]; + const toolDir = path.join(downloadDir, archiveName); + let installDir = toolDir; + if (!utils_1.isNightlyKeyword(resolvedPyPyVersion)) { + installDir = yield tc.cacheDir(toolDir, 'PyPy', resolvedPythonVersion, architecture); + } + utils_1.writeExactPyPyVersionFile(installDir, resolvedPyPyVersion); + const binaryPath = getPyPyBinaryPath(installDir); + yield createPyPySymlink(binaryPath, resolvedPythonVersion); + yield installPip(binaryPath); + return { installDir, resolvedPythonVersion, resolvedPyPyVersion }; + }); +} +exports.installPyPy = installPyPy; +function getAvailablePyPyVersions() { + return __awaiter(this, void 0, void 0, function* () { + const url = 'https://downloads.python.org/pypy/versions.json'; + const http = new httpm.HttpClient('tool-cache'); + const response = yield http.getJson(url); + if (!response.result) { + throw new Error(`Unable to retrieve the list of available PyPy versions from '${url}'`); + } + return response.result; + }); +} +function createPyPySymlink(pypyBinaryPath, pythonVersion) { + return __awaiter(this, void 0, void 0, function* () { + const version = semver.coerce(pythonVersion); + const pythonBinaryPostfix = semver.major(version); + const pypyBinaryPostfix = pythonBinaryPostfix === 2 ? '' : '3'; + let binaryExtension = utils_1.IS_WINDOWS ? '.exe' : ''; + core.info('Creating symlinks...'); + utils_1.createSymlinkInFolder(pypyBinaryPath, `pypy${pypyBinaryPostfix}${binaryExtension}`, `python${pythonBinaryPostfix}${binaryExtension}`, true); + utils_1.createSymlinkInFolder(pypyBinaryPath, `pypy${pypyBinaryPostfix}${binaryExtension}`, `python${binaryExtension}`, true); + }); +} +function installPip(pythonLocation) { + return __awaiter(this, void 0, void 0, function* () { + core.info('Installing and updating pip'); + const pythonBinary = path.join(pythonLocation, 'python'); + yield exec.exec(`${pythonBinary} -m ensurepip`); + yield exec.exec(`${pythonLocation}/python -m pip install --ignore-installed pip`); + }); +} +function findRelease(releases, pythonVersion, pypyVersion, architecture) { + 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); + const isArchPresent = item.files && + (utils_1.IS_WINDOWS + ? isArchPresentForWindows(item) + : isArchPresentForMacOrLinux(item, architecture, process.platform)); + return isPythonVersionSatisfied && isPyPyVersionSatisfied && isArchPresent; + }); + if (filterReleases.length === 0) { + return null; + } + const sortedReleases = filterReleases.sort((previous, current) => { + return (semver.compare(semver.coerce(pypyVersionToSemantic(current.pypy_version)), semver.coerce(pypyVersionToSemantic(previous.pypy_version))) || + semver.compare(semver.coerce(current.python_version), semver.coerce(previous.python_version))); + }); + const foundRelease = sortedReleases[0]; + const foundAsset = utils_1.IS_WINDOWS + ? findAssetForWindows(foundRelease) + : findAssetForMacOrLinux(foundRelease, architecture, process.platform); + return { + foundAsset, + resolvedPythonVersion: foundRelease.python_version, + resolvedPyPyVersion: foundRelease.pypy_version + }; +} +exports.findRelease = findRelease; +/** Get PyPy binary location from the tool of installation directory + * - On Linux and macOS, the Python interpreter is in 'bin'. + * - On Windows, it is in the installation root. + */ +function getPyPyBinaryPath(installDir) { + const _binDir = path.join(installDir, 'bin'); + return utils_1.IS_WINDOWS ? installDir : _binDir; +} +exports.getPyPyBinaryPath = getPyPyBinaryPath; +function pypyVersionToSemantic(versionSpec) { + const prereleaseVersion = /(\d+\.\d+\.\d+)((?:a|b|rc))(\d*)/g; + return versionSpec.replace(prereleaseVersion, '$1-$2.$3'); +} +exports.pypyVersionToSemantic = pypyVersionToSemantic; +function isArchPresentForWindows(item) { + return item.files.some((file) => utils_1.WINDOWS_ARCHS.includes(file.arch) && + utils_1.WINDOWS_PLATFORMS.includes(file.platform)); +} +exports.isArchPresentForWindows = isArchPresentForWindows; +function isArchPresentForMacOrLinux(item, architecture, platform) { + return item.files.some((file) => file.arch === architecture && file.platform === platform); +} +exports.isArchPresentForMacOrLinux = isArchPresentForMacOrLinux; +function findAssetForWindows(releases) { + return releases.files.find((item) => utils_1.WINDOWS_ARCHS.includes(item.arch) && + utils_1.WINDOWS_PLATFORMS.includes(item.platform)); +} +exports.findAssetForWindows = findAssetForWindows; +function findAssetForMacOrLinux(releases, architecture, platform) { + return releases.files.find((item) => item.arch === architecture && item.platform === platform); +} +exports.findAssetForMacOrLinux = findAssetForMacOrLinux; + + /***/ }), /***/ 413: @@ -6422,16 +6798,17 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", { value: true }); +const path = __importStar(__webpack_require__(622)); const core = __importStar(__webpack_require__(470)); const tc = __importStar(__webpack_require__(533)); const exec = __importStar(__webpack_require__(986)); +const utils_1 = __webpack_require__(163); const TOKEN = core.getInput('token'); const AUTH = !TOKEN || isGhes() ? undefined : `token ${TOKEN}`; const MANIFEST_REPO_OWNER = 'actions'; const MANIFEST_REPO_NAME = 'python-versions'; const MANIFEST_REPO_BRANCH = 'main'; exports.MANIFEST_URL = `https://raw.githubusercontent.com/${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}/${MANIFEST_REPO_BRANCH}/versions-manifest.json`; -const IS_WINDOWS = process.platform === 'win32'; function findReleaseFromManifest(semanticVersionSpec, architecture) { return __awaiter(this, void 0, void 0, function* () { const manifest = yield tc.getManifestFromRepo(MANIFEST_REPO_OWNER, MANIFEST_REPO_NAME, AUTH, MANIFEST_REPO_BRANCH); @@ -6443,6 +6820,7 @@ function installPython(workingDirectory) { return __awaiter(this, void 0, void 0, function* () { const options = { cwd: workingDirectory, + env: Object.assign(Object.assign({}, process.env), (utils_1.IS_LINUX && { LD_LIBRARY_PATH: path.join(workingDirectory, 'lib') })), silent: true, listeners: { stdout: (data) => { @@ -6453,7 +6831,7 @@ function installPython(workingDirectory) { } } }; - if (IS_WINDOWS) { + if (utils_1.IS_WINDOWS) { yield exec.exec('powershell', ['./setup.ps1'], options); } else { @@ -6468,7 +6846,7 @@ function installCpythonFromRelease(release) { const pythonPath = yield tc.downloadTool(downloadUrl, undefined, AUTH); core.info('Extract downloaded archive'); let pythonExtractedFolder; - if (IS_WINDOWS) { + if (utils_1.IS_WINDOWS) { pythonExtractedFolder = yield tc.extractZip(pythonPath); } else { @@ -6683,11 +7061,11 @@ var __importStar = (this && this.__importStar) || function (mod) { Object.defineProperty(exports, "__esModule", { value: true }); const os = __importStar(__webpack_require__(87)); const path = __importStar(__webpack_require__(622)); +const utils_1 = __webpack_require__(163); const semver = __importStar(__webpack_require__(876)); const installer = __importStar(__webpack_require__(824)); const core = __importStar(__webpack_require__(470)); const tc = __importStar(__webpack_require__(533)); -const IS_WINDOWS = process.platform === 'win32'; // Python has "scripts" or "bin" directories where command-line tools that come with packages are installed. // This is where pip is, along with anything that pip installs. // There is a seperate directory for `pip install --user`. @@ -6701,7 +7079,7 @@ const IS_WINDOWS = process.platform === 'win32'; // (--user) %APPDATA%\Python\PythonXY\Scripts // See https://docs.python.org/3/library/sysconfig.html function binDir(installDir) { - if (IS_WINDOWS) { + if (utils_1.IS_WINDOWS) { return path.join(installDir, 'Scripts'); } else { @@ -6714,9 +7092,9 @@ function binDir(installDir) { // For example, PyPy 7.0 contains Python 2.7, 3.5, and 3.6-alpha. // We only care about the Python version, so we don't use the PyPy version for the tool cache. function usePyPy(majorVersion, architecture) { - const findPyPy = tc.find.bind(undefined, 'PyPy', majorVersion.toString()); + const findPyPy = tc.find.bind(undefined, 'PyPy', majorVersion); let installDir = findPyPy(architecture); - if (!installDir && IS_WINDOWS) { + if (!installDir && utils_1.IS_WINDOWS) { // PyPy only precompiles binaries for x86, but the architecture parameter defaults to x64. // On our Windows virtual environments, we only install an x86 version. // Fall back to x86. @@ -6730,10 +7108,14 @@ function usePyPy(majorVersion, architecture) { const _binDir = path.join(installDir, 'bin'); // On Linux and macOS, the Python interpreter is in 'bin'. // On Windows, it is in the installation root. - const pythonLocation = IS_WINDOWS ? installDir : _binDir; + const pythonLocation = utils_1.IS_WINDOWS ? installDir : _binDir; core.exportVariable('pythonLocation', pythonLocation); core.addPath(installDir); core.addPath(_binDir); + // Starting from PyPy 7.3.1, the folder that is used for pip and anything that pip installs should be "Scripts" on Windows. + if (utils_1.IS_WINDOWS) { + core.addPath(path.join(installDir, 'Scripts')); + } const impl = 'pypy' + majorVersion.toString(); core.setOutput('python-version', impl); return { impl: impl, version: versionFromPath(installDir) }; @@ -6760,9 +7142,18 @@ function useCpythonVersion(version, architecture) { ].join(os.EOL)); } core.exportVariable('pythonLocation', installDir); + if (utils_1.IS_LINUX) { + const libPath = process.env.LD_LIBRARY_PATH + ? `:${process.env.LD_LIBRARY_PATH}` + : ''; + const pyLibPath = path.join(installDir, 'lib'); + if (!libPath.split(':').includes(pyLibPath)) { + core.exportVariable('LD_LIBRARY_PATH', pyLibPath + libPath); + } + } core.addPath(installDir); core.addPath(binDir(installDir)); - if (IS_WINDOWS) { + if (utils_1.IS_WINDOWS) { // Add --user directory // `installDir` from tool cache should look like $RUNNER_TOOL_CACHE/Python//x64/ // So if `findLocalTool` succeeded above, we must have a conformant `installDir` @@ -6808,9 +7199,10 @@ function findPythonVersion(version, architecture) { return __awaiter(this, void 0, void 0, function* () { switch (version.toUpperCase()) { case 'PYPY2': - return usePyPy(2, architecture); + return usePyPy('2', architecture); case 'PYPY3': - return usePyPy(3, architecture); + // keep pypy3 pointing to 3.6 for backward compatibility + return usePyPy('3.6', architecture); default: return yield useCpythonVersion(version, architecture); } diff --git a/docs/contributors.md b/docs/contributors.md index 92e6d4ded..2833bb167 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -13,12 +13,14 @@ In order to avoid uploading `node_modules/` to the repository, we use [vercel/nc ### Developing If you're developing locally, you can run -``` + +```sh npm install tsc ncc build src/setup-python.ts ``` -Any files generated using `tsc` will be added to `lib/`, however those files also are not uploaded to the repository and are exluded using `.gitignore`. + +Any files generated using `tsc` will be added to `lib/`, however those files also are not uploaded to the repository and are excluded using `.gitignore`. During the commit step, Husky will take care of formatting all files with [Prettier](https://github.com/prettier/prettier) (to run manually, use `npm run format`). diff --git a/src/find-pypy.ts b/src/find-pypy.ts new file mode 100644 index 000000000..eb8dfac65 --- /dev/null +++ b/src/find-pypy.ts @@ -0,0 +1,140 @@ +import * as path from 'path'; +import * as pypyInstall from './install-pypy'; +import { + IS_WINDOWS, + WINDOWS_ARCHS, + validateVersion, + getPyPyVersionFromPath, + readExactPyPyVersionFile, + validatePythonVersionFormatForPyPy +} from './utils'; + +import * as semver from 'semver'; +import * as core from '@actions/core'; +import * as tc from '@actions/tool-cache'; + +interface IPyPyVersionSpec { + pypyVersion: string; + pythonVersion: string; +} + +export async function findPyPyVersion( + versionSpec: string, + architecture: string +): Promise<{resolvedPyPyVersion: string; resolvedPythonVersion: string}> { + let resolvedPyPyVersion = ''; + let resolvedPythonVersion = ''; + let installDir: string | null; + + const pypyVersionSpec = parsePyPyVersion(versionSpec); + + ({installDir, resolvedPythonVersion, resolvedPyPyVersion} = findPyPyToolCache( + pypyVersionSpec.pythonVersion, + pypyVersionSpec.pypyVersion, + architecture + )); + + if (!installDir) { + ({ + installDir, + resolvedPythonVersion, + resolvedPyPyVersion + } = await pypyInstall.installPyPy( + pypyVersionSpec.pypyVersion, + pypyVersionSpec.pythonVersion, + architecture + )); + } + + const pipDir = IS_WINDOWS ? 'Scripts' : 'bin'; + const _binDir = path.join(installDir, pipDir); + const pythonLocation = pypyInstall.getPyPyBinaryPath(installDir); + core.exportVariable('pythonLocation', pythonLocation); + core.addPath(pythonLocation); + core.addPath(_binDir); + + return {resolvedPyPyVersion, resolvedPythonVersion}; +} + +export function findPyPyToolCache( + pythonVersion: string, + pypyVersion: string, + architecture: string +) { + let resolvedPyPyVersion = ''; + let resolvedPythonVersion = ''; + let installDir: string | null = IS_WINDOWS + ? findPyPyInstallDirForWindows(pythonVersion) + : tc.find('PyPy', pythonVersion, architecture); + + if (installDir) { + // 'tc.find' finds tool based on Python version but we also need to check + // whether PyPy version satisfies requested version. + resolvedPythonVersion = getPyPyVersionFromPath(installDir); + resolvedPyPyVersion = readExactPyPyVersionFile(installDir); + + const isPyPyVersionSatisfies = semver.satisfies( + resolvedPyPyVersion, + pypyVersion + ); + if (!isPyPyVersionSatisfies) { + installDir = null; + resolvedPyPyVersion = ''; + resolvedPythonVersion = ''; + } + } + + if (!installDir) { + core.info( + `PyPy version ${pythonVersion} (${pypyVersion}) was not found in the local cache` + ); + } + + return {installDir, resolvedPythonVersion, resolvedPyPyVersion}; +} + +export function parsePyPyVersion(versionSpec: string): IPyPyVersionSpec { + const versions = versionSpec.split('-').filter(item => !!item); + + if (versions.length < 2 || versions[0] != 'pypy') { + throw new Error( + "Invalid 'version' property for PyPy. PyPy version should be specified as 'pypy-'. See README for examples and documentation." + ); + } + + const pythonVersion = versions[1]; + let pypyVersion: string; + if (versions.length > 2) { + pypyVersion = pypyInstall.pypyVersionToSemantic(versions[2]); + } else { + pypyVersion = 'x'; + } + + if (!validateVersion(pythonVersion) || !validateVersion(pypyVersion)) { + throw new Error( + "Invalid 'version' property for PyPy. Both Python version and PyPy versions should satisfy SemVer notation. See README for examples and documentation." + ); + } + + if (!validatePythonVersionFormatForPyPy(pythonVersion)) { + throw new Error( + "Invalid format of Python version for PyPy. Python version should be specified in format 'x.y'. See README for examples and documentation." + ); + } + + return { + pypyVersion: pypyVersion, + pythonVersion: pythonVersion + }; +} + +export function findPyPyInstallDirForWindows(pythonVersion: string): string { + let installDir = ''; + + WINDOWS_ARCHS.forEach( + architecture => + (installDir = installDir || tc.find('PyPy', pythonVersion, architecture)) + ); + + return installDir; +} diff --git a/src/find-python.ts b/src/find-python.ts index db06230d4..ff2a20d8d 100644 --- a/src/find-python.ts +++ b/src/find-python.ts @@ -1,5 +1,6 @@ import * as os from 'os'; import * as path from 'path'; +import {IS_WINDOWS, IS_LINUX} from './utils'; import * as semver from 'semver'; @@ -8,8 +9,6 @@ import * as installer from './install-python'; import * as core from '@actions/core'; import * as tc from '@actions/tool-cache'; -const IS_WINDOWS = process.platform === 'win32'; - // Python has "scripts" or "bin" directories where command-line tools that come with packages are installed. // This is where pip is, along with anything that pip installs. // There is a seperate directory for `pip install --user`. @@ -36,8 +35,11 @@ function binDir(installDir: string): string { // A particular version of PyPy may contain one or more versions of the Python interpreter. // For example, PyPy 7.0 contains Python 2.7, 3.5, and 3.6-alpha. // We only care about the Python version, so we don't use the PyPy version for the tool cache. -function usePyPy(majorVersion: 2 | 3, architecture: string): InstalledVersion { - const findPyPy = tc.find.bind(undefined, 'PyPy', majorVersion.toString()); +function usePyPy( + majorVersion: '2' | '3.6', + architecture: string +): InstalledVersion { + const findPyPy = tc.find.bind(undefined, 'PyPy', majorVersion); let installDir: string | null = findPyPy(architecture); if (!installDir && IS_WINDOWS) { @@ -62,6 +64,10 @@ function usePyPy(majorVersion: 2 | 3, architecture: string): InstalledVersion { core.addPath(installDir); core.addPath(_binDir); + // Starting from PyPy 7.3.1, the folder that is used for pip and anything that pip installs should be "Scripts" on Windows. + if (IS_WINDOWS) { + core.addPath(path.join(installDir, 'Scripts')); + } const impl = 'pypy' + majorVersion.toString(); core.setOutput('python-version', impl); @@ -109,6 +115,18 @@ async function useCpythonVersion( } core.exportVariable('pythonLocation', installDir); + + if (IS_LINUX) { + const libPath = process.env.LD_LIBRARY_PATH + ? `:${process.env.LD_LIBRARY_PATH}` + : ''; + const pyLibPath = path.join(installDir, 'lib'); + + if (!libPath.split(':').includes(pyLibPath)) { + core.exportVariable('LD_LIBRARY_PATH', pyLibPath + libPath); + } + } + core.addPath(installDir); core.addPath(binDir(installDir)); @@ -175,9 +193,10 @@ export async function findPythonVersion( ): Promise { switch (version.toUpperCase()) { case 'PYPY2': - return usePyPy(2, architecture); + return usePyPy('2', architecture); case 'PYPY3': - return usePyPy(3, architecture); + // keep pypy3 pointing to 3.6 for backward compatibility + return usePyPy('3.6', architecture); default: return await useCpythonVersion(version, architecture); } diff --git a/src/install-pypy.ts b/src/install-pypy.ts new file mode 100644 index 000000000..402525ab3 --- /dev/null +++ b/src/install-pypy.ts @@ -0,0 +1,231 @@ +import * as path from 'path'; +import * as core from '@actions/core'; +import * as tc from '@actions/tool-cache'; +import * as semver from 'semver'; +import * as httpm from '@actions/http-client'; +import * as exec from '@actions/exec'; +import fs from 'fs'; + +import { + IS_WINDOWS, + WINDOWS_ARCHS, + WINDOWS_PLATFORMS, + IPyPyManifestRelease, + createSymlinkInFolder, + isNightlyKeyword, + writeExactPyPyVersionFile +} from './utils'; + +export async function installPyPy( + pypyVersion: string, + pythonVersion: string, + architecture: string +) { + let downloadDir; + + const releases = await getAvailablePyPyVersions(); + if (!releases || releases.length === 0) { + throw new Error('No release was found in PyPy version.json'); + } + + const releaseData = findRelease( + releases, + pythonVersion, + pypyVersion, + architecture + ); + + if (!releaseData || !releaseData.foundAsset) { + throw new Error( + `PyPy version ${pythonVersion} (${pypyVersion}) with arch ${architecture} not found` + ); + } + + const {foundAsset, resolvedPythonVersion, resolvedPyPyVersion} = releaseData; + let downloadUrl = `${foundAsset.download_url}`; + + core.info(`Downloading PyPy from "${downloadUrl}" ...`); + const pypyPath = await tc.downloadTool(downloadUrl); + + core.info('Extracting downloaded archive...'); + if (IS_WINDOWS) { + downloadDir = await tc.extractZip(pypyPath); + } else { + downloadDir = await tc.extractTar(pypyPath, undefined, 'x'); + } + + // root folder in archive can have unpredictable name so just take the first folder + // downloadDir is unique folder under TEMP and can't contain any other folders + const archiveName = fs.readdirSync(downloadDir)[0]; + + const toolDir = path.join(downloadDir, archiveName); + let installDir = toolDir; + if (!isNightlyKeyword(resolvedPyPyVersion)) { + installDir = await tc.cacheDir( + toolDir, + 'PyPy', + resolvedPythonVersion, + architecture + ); + } + + writeExactPyPyVersionFile(installDir, resolvedPyPyVersion); + + const binaryPath = getPyPyBinaryPath(installDir); + await createPyPySymlink(binaryPath, resolvedPythonVersion); + await installPip(binaryPath); + + return {installDir, resolvedPythonVersion, resolvedPyPyVersion}; +} + +async function getAvailablePyPyVersions() { + const url = 'https://downloads.python.org/pypy/versions.json'; + const http: httpm.HttpClient = new httpm.HttpClient('tool-cache'); + + const response = await http.getJson(url); + if (!response.result) { + throw new Error( + `Unable to retrieve the list of available PyPy versions from '${url}'` + ); + } + + return response.result; +} + +async function createPyPySymlink( + pypyBinaryPath: string, + pythonVersion: string +) { + const version = semver.coerce(pythonVersion)!; + const pythonBinaryPostfix = semver.major(version); + const pypyBinaryPostfix = pythonBinaryPostfix === 2 ? '' : '3'; + let binaryExtension = IS_WINDOWS ? '.exe' : ''; + + core.info('Creating symlinks...'); + createSymlinkInFolder( + pypyBinaryPath, + `pypy${pypyBinaryPostfix}${binaryExtension}`, + `python${pythonBinaryPostfix}${binaryExtension}`, + true + ); + + createSymlinkInFolder( + pypyBinaryPath, + `pypy${pypyBinaryPostfix}${binaryExtension}`, + `python${binaryExtension}`, + true + ); +} + +async function installPip(pythonLocation: string) { + core.info('Installing and updating pip'); + const pythonBinary = path.join(pythonLocation, 'python'); + await exec.exec(`${pythonBinary} -m ensurepip`); + + await exec.exec( + `${pythonLocation}/python -m pip install --ignore-installed pip` + ); +} + +export function findRelease( + releases: IPyPyManifestRelease[], + pythonVersion: string, + pypyVersion: string, + architecture: string +) { + const filterReleases = releases.filter(item => { + const isPythonVersionSatisfied = semver.satisfies( + semver.coerce(item.python_version)!, + pythonVersion + ); + const isPyPyNightly = + isNightlyKeyword(pypyVersion) && isNightlyKeyword(item.pypy_version); + const isPyPyVersionSatisfied = + isPyPyNightly || + semver.satisfies(pypyVersionToSemantic(item.pypy_version), pypyVersion); + const isArchPresent = + item.files && + (IS_WINDOWS + ? isArchPresentForWindows(item) + : isArchPresentForMacOrLinux(item, architecture, process.platform)); + return isPythonVersionSatisfied && isPyPyVersionSatisfied && isArchPresent; + }); + + if (filterReleases.length === 0) { + return null; + } + + const sortedReleases = filterReleases.sort((previous, current) => { + return ( + semver.compare( + semver.coerce(pypyVersionToSemantic(current.pypy_version))!, + semver.coerce(pypyVersionToSemantic(previous.pypy_version))! + ) || + semver.compare( + semver.coerce(current.python_version)!, + semver.coerce(previous.python_version)! + ) + ); + }); + + const foundRelease = sortedReleases[0]; + const foundAsset = IS_WINDOWS + ? findAssetForWindows(foundRelease) + : findAssetForMacOrLinux(foundRelease, architecture, process.platform); + + return { + foundAsset, + resolvedPythonVersion: foundRelease.python_version, + resolvedPyPyVersion: foundRelease.pypy_version + }; +} + +/** Get PyPy binary location from the tool of installation directory + * - On Linux and macOS, the Python interpreter is in 'bin'. + * - On Windows, it is in the installation root. + */ +export function getPyPyBinaryPath(installDir: string) { + const _binDir = path.join(installDir, 'bin'); + return IS_WINDOWS ? installDir : _binDir; +} + +export function pypyVersionToSemantic(versionSpec: string) { + const prereleaseVersion = /(\d+\.\d+\.\d+)((?:a|b|rc))(\d*)/g; + return versionSpec.replace(prereleaseVersion, '$1-$2.$3'); +} + +export function isArchPresentForWindows(item: any) { + return item.files.some( + (file: any) => + WINDOWS_ARCHS.includes(file.arch) && + WINDOWS_PLATFORMS.includes(file.platform) + ); +} + +export function isArchPresentForMacOrLinux( + item: any, + architecture: string, + platform: string +) { + return item.files.some( + (file: any) => file.arch === architecture && file.platform === platform + ); +} + +export function findAssetForWindows(releases: any) { + return releases.files.find( + (item: any) => + WINDOWS_ARCHS.includes(item.arch) && + WINDOWS_PLATFORMS.includes(item.platform) + ); +} + +export function findAssetForMacOrLinux( + releases: any, + architecture: string, + platform: string +) { + return releases.files.find( + (item: any) => item.arch === architecture && item.platform === platform + ); +} diff --git a/src/install-python.ts b/src/install-python.ts index 36d88781c..526e7d59d 100644 --- a/src/install-python.ts +++ b/src/install-python.ts @@ -3,7 +3,7 @@ import * as core from '@actions/core'; import * as tc from '@actions/tool-cache'; import * as exec from '@actions/exec'; import {ExecOptions} from '@actions/exec/lib/interfaces'; -import {stderr} from 'process'; +import {IS_WINDOWS, IS_LINUX} from './utils'; const TOKEN = core.getInput('token'); const AUTH = !TOKEN || isGhes() ? undefined : `token ${TOKEN}`; @@ -12,8 +12,6 @@ const MANIFEST_REPO_NAME = 'python-versions'; const MANIFEST_REPO_BRANCH = 'main'; export const MANIFEST_URL = `https://raw.githubusercontent.com/${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}/${MANIFEST_REPO_BRANCH}/versions-manifest.json`; -const IS_WINDOWS = process.platform === 'win32'; - export async function findReleaseFromManifest( semanticVersionSpec: string, architecture: string @@ -35,6 +33,10 @@ export async function findReleaseFromManifest( async function installPython(workingDirectory: string) { const options: ExecOptions = { cwd: workingDirectory, + env: { + ...process.env, + ...(IS_LINUX && {LD_LIBRARY_PATH: path.join(workingDirectory, 'lib')}) + }, silent: true, listeners: { stdout: (data: Buffer) => { diff --git a/src/setup-python.ts b/src/setup-python.ts index c97f314ca..15e46956b 100644 --- a/src/setup-python.ts +++ b/src/setup-python.ts @@ -1,15 +1,29 @@ import * as core from '@actions/core'; import * as finder from './find-python'; +import * as finderPyPy from './find-pypy'; import * as path from 'path'; import * as os from 'os'; +function isPyPyVersion(versionSpec: string) { + return versionSpec.startsWith('pypy-'); +} + async function run() { try { let version = core.getInput('python-version'); if (version) { const arch: string = core.getInput('architecture') || os.arch(); - const installed = await finder.findPythonVersion(version, arch); - core.info(`Successfully setup ${installed.impl} (${installed.version})`); + if (isPyPyVersion(version)) { + const installed = await finderPyPy.findPyPyVersion(version, arch); + core.info( + `Successfully setup PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})` + ); + } else { + const installed = await finder.findPythonVersion(version, arch); + core.info( + `Successfully setup ${installed.impl} (${installed.version})` + ); + } } const matchersPath = path.join(__dirname, '..', '.github'); core.info(`##[add-matcher]${path.join(matchersPath, 'python.json')}`); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 000000000..c15fe3d05 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,94 @@ +import fs from 'fs'; +import * as path from 'path'; +import * as semver from 'semver'; + +export const IS_WINDOWS = process.platform === 'win32'; +export const IS_LINUX = process.platform === 'linux'; +export const WINDOWS_ARCHS = ['x86', 'x64']; +export const WINDOWS_PLATFORMS = ['win32', 'win64']; +const PYPY_VERSION_FILE = 'PYPY_VERSION'; + +export interface IPyPyManifestAsset { + filename: string; + arch: string; + platform: string; + download_url: string; +} + +export interface IPyPyManifestRelease { + pypy_version: string; + python_version: string; + stable: boolean; + latest_pypy: boolean; + files: IPyPyManifestAsset[]; +} + +/** create Symlinks for downloaded PyPy + * It should be executed only for downloaded versions in runtime, because + * toolcache versions have this setup. + */ +export function createSymlinkInFolder( + folderPath: string, + sourceName: string, + targetName: string, + setExecutable = false +) { + const sourcePath = path.join(folderPath, sourceName); + const targetPath = path.join(folderPath, targetName); + if (fs.existsSync(targetPath)) { + return; + } + + fs.symlinkSync(sourcePath, targetPath); + if (!IS_WINDOWS && setExecutable) { + fs.chmodSync(targetPath, '755'); + } +} + +export function validateVersion(version: string) { + return isNightlyKeyword(version) || Boolean(semver.validRange(version)); +} + +export function isNightlyKeyword(pypyVersion: string) { + return pypyVersion === 'nightly'; +} + +export function getPyPyVersionFromPath(installDir: string) { + return path.basename(path.dirname(installDir)); +} + +/** + * In tool-cache, we put PyPy to '/PyPy//x64' + * There is no easy way to determine what PyPy version is located in specific folder + * 'pypy --version' is not reliable enough since it is not set properly for preview versions + * "7.3.3rc1" is marked as '7.3.3' in 'pypy --version' + * so we put PYPY_VERSION file to PyPy directory when install it to VM and read it when we need to know version + * PYPY_VERSION contains exact version from 'versions.json' + */ +export function readExactPyPyVersionFile(installDir: string) { + let pypyVersion = ''; + let fileVersion = path.join(installDir, PYPY_VERSION_FILE); + if (fs.existsSync(fileVersion)) { + pypyVersion = fs.readFileSync(fileVersion).toString(); + } + + return pypyVersion; +} + +export function writeExactPyPyVersionFile( + installDir: string, + resolvedPyPyVersion: string +) { + const pypyFilePath = path.join(installDir, PYPY_VERSION_FILE); + fs.writeFileSync(pypyFilePath, resolvedPyPyVersion); +} + +/** + * Python version should be specified explicitly like "x.y" (2.7, 3.6, 3.7) + * "3.x" or "3" are not supported + * because it could cause ambiguity when both PyPy version and Python version are not precise + */ +export function validatePythonVersionFormatForPyPy(version: string) { + const re = /^\d+\.\d+$/; + return re.test(version); +}