diff --git a/__tests__/install-graalpy.test.ts b/__tests__/install-graalpy.test.ts index fda3348f4..6b18308f4 100644 --- a/__tests__/install-graalpy.test.ts +++ b/__tests__/install-graalpy.test.ts @@ -32,6 +32,35 @@ describe('graalpyVersionToSemantic', () => { }); }); +describe('GraalPy release URL overrides', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns default URLs when no overrides are set', () => { + jest.spyOn(core, 'getInput').mockReturnValue(''); + expect(installer.getGraalPyReleasesUrl()).toBe( + 'https://api.github.com/repos/oracle/graalpython/releases' + ); + expect(installer.getGraalPyPrereleaseReleasesUrl()).toBe( + 'https://api.github.com/repos/graalvm/graal-languages-ea-builds/releases' + ); + }); + + it('returns the graalpy-*-releases-url inputs when provided', () => { + const stable = 'https://mirror.example.com/graalpython/releases'; + const prerelease = + 'https://mirror.example.com/graal-languages-ea-builds/releases'; + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + if (name === 'graalpy-releases-url') return stable; + if (name === 'graalpy-prerelease-releases-url') return prerelease; + return ''; + }); + expect(installer.getGraalPyReleasesUrl()).toBe(stable); + expect(installer.getGraalPyPrereleaseReleasesUrl()).toBe(prerelease); + }); +}); + describe('findRelease', () => { const result = JSON.stringify(manifestData); const releases = JSON.parse(result) as IGraalPyManifestRelease[]; diff --git a/__tests__/install-pypy.test.ts b/__tests__/install-pypy.test.ts index f3ed1e9c9..d7e7fa726 100644 --- a/__tests__/install-pypy.test.ts +++ b/__tests__/install-pypy.test.ts @@ -38,6 +38,28 @@ describe('pypyVersionToSemantic', () => { }); }); +describe('getPyPyVersionsUrl', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns the default URL when no override is set', () => { + jest.spyOn(core, 'getInput').mockReturnValue(''); + expect(installer.getPyPyVersionsUrl()).toBe( + 'https://downloads.python.org/pypy/versions.json' + ); + }); + + it('returns the pypy-versions-url input when provided', () => { + const override = 'https://mirror.example.com/pypy/versions.json'; + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + if (name === 'pypy-versions-url') return override; + return ''; + }); + expect(installer.getPyPyVersionsUrl()).toBe(override); + }); +}); + describe('findRelease', () => { const result = JSON.stringify(manifestData); const releases = JSON.parse(result) as IPyPyManifestRelease[]; diff --git a/__tests__/install-python.test.ts b/__tests__/install-python.test.ts index 51f9fa77f..603376457 100644 --- a/__tests__/install-python.test.ts +++ b/__tests__/install-python.test.ts @@ -1,10 +1,12 @@ import { getManifest, getManifestFromRepo, - getManifestFromURL + getManifestFromURL, + getManifestUrl } from '../src/install-python'; import * as httpm from '@actions/http-client'; import * as tc from '@actions/tool-cache'; +import * as core from '@actions/core'; jest.mock('@actions/http-client'); jest.mock('@actions/tool-cache'); @@ -74,4 +76,88 @@ describe('getManifestFromURL', () => { 'Unable to get manifest from' ); }); + + it('should fetch from the manifest-url override when provided', async () => { + const override = 'https://mirror.example.com/versions-manifest.json'; + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + if (name === 'manifest-url') return override; + return ''; + }); + const getJson = jest + .spyOn(httpm.HttpClient.prototype, 'getJson') + .mockResolvedValue({result: mockManifest} as any); + + await getManifestFromURL(); + expect(getJson).toHaveBeenCalledWith(override); + }); +}); + +describe('getManifestUrl', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns the default URL when no overrides are set', () => { + jest.spyOn(core, 'getInput').mockReturnValue(''); + expect(getManifestUrl()).toBe( + 'https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json' + ); + }); + + it('returns the manifest-url input when provided', () => { + const override = 'https://mirror.example.com/versions-manifest.json'; + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + if (name === 'manifest-url') return override; + return ''; + }); + expect(getManifestUrl()).toBe(override); + }); + + it('composes the URL from the manifest-repo-* inputs', () => { + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + switch (name) { + case 'manifest-repo-owner': + return 'my-org'; + case 'manifest-repo-name': + return 'my-repo'; + case 'manifest-repo-branch': + return 'release'; + default: + return ''; + } + }); + expect(getManifestUrl()).toBe( + 'https://raw.githubusercontent.com/my-org/my-repo/release/versions-manifest.json' + ); + }); +}); + +describe('getManifestFromRepo overrides', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('forwards the manifest-repo-* inputs to tc.getManifestFromRepo', async () => { + jest.spyOn(core, 'getInput').mockImplementation((name: string) => { + switch (name) { + case 'manifest-repo-owner': + return 'my-org'; + case 'manifest-repo-name': + return 'my-repo'; + case 'manifest-repo-branch': + return 'release'; + default: + return ''; + } + }); + (tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest); + + await getManifestFromRepo(); + expect(tc.getManifestFromRepo).toHaveBeenCalledWith( + 'my-org', + 'my-repo', + undefined, + 'release' + ); + }); }); diff --git a/action.yml b/action.yml index 7a9a7b634..b19285334 100644 --- a/action.yml +++ b/action.yml @@ -33,6 +33,23 @@ inputs: description: "Used to specify the version of pip to install with the Python. Supported format: major[.minor][.patch]." pip-install: description: "Used to specify the packages to install with pip after setting up Python. Can be a requirements file or package names." + manifest-url: + description: "URL of the CPython versions-manifest.json. Override when mirroring actions/python-versions to a self-hosted source." + manifest-repo-owner: + description: "Owner of the repository hosting the CPython versions-manifest.json. Used by the GitHub API fallback." + default: "actions" + manifest-repo-name: + description: "Name of the repository hosting the CPython versions-manifest.json." + default: "python-versions" + manifest-repo-branch: + description: "Branch of the repository hosting the CPython versions-manifest.json." + default: "main" + pypy-versions-url: + description: "URL of the PyPy versions.json. Override when mirroring downloads.python.org/pypy." + graalpy-releases-url: + description: "URL returning the GraalPy stable releases (mirror of api.github.com/repos/oracle/graalpython/releases)." + graalpy-prerelease-releases-url: + description: "URL returning the GraalPy pre-release builds (mirror of api.github.com/repos/graalvm/graal-languages-ea-builds/releases)." 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 84d9adb4a..f88eafcd4 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -54654,7 +54654,7 @@ async function useCpythonVersion(version, architecture, updateEnvironment, check if (freethreaded) { msg.push(`Free threaded versions are only available for Python 3.13.0 and later.`); } - msg.push(`The list of all available versions can be found here: ${installer.MANIFEST_URL}`); + msg.push(`The list of all available versions can be found here: ${installer.getManifestUrl()}`); throw new Error(msg.join(os.EOL)); } const _binDir = binDir(installDir); @@ -54821,6 +54821,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.getGraalPyReleasesUrl = getGraalPyReleasesUrl; +exports.getGraalPyPrereleaseReleasesUrl = getGraalPyPrereleaseReleasesUrl; exports.installGraalPy = installGraalPy; exports.getAvailableGraalPyVersions = getAvailableGraalPyVersions; exports.graalPyTagToVersion = graalPyTagToVersion; @@ -54839,6 +54841,15 @@ const fs_1 = __importDefault(__nccwpck_require__(79896)); const utils_1 = __nccwpck_require__(71798); const TOKEN = core.getInput('token'); const AUTH = !TOKEN ? undefined : `token ${TOKEN}`; +const DEFAULT_GRAALPY_RELEASES_URL = 'https://api.github.com/repos/oracle/graalpython/releases'; +const DEFAULT_GRAALPY_PRERELEASE_RELEASES_URL = 'https://api.github.com/repos/graalvm/graal-languages-ea-builds/releases'; +function getGraalPyReleasesUrl() { + return core.getInput('graalpy-releases-url') || DEFAULT_GRAALPY_RELEASES_URL; +} +function getGraalPyPrereleaseReleasesUrl() { + return (core.getInput('graalpy-prerelease-releases-url') || + DEFAULT_GRAALPY_PRERELEASE_RELEASES_URL); +} async function installGraalPy(graalpyVersion, architecture, allowPreReleases, releases) { let downloadDir; releases = releases ?? (await getAvailableGraalPyVersions()); @@ -54908,7 +54919,7 @@ async function getAvailableGraalPyVersions() { /* Get releases first. */ - let url = 'https://api.github.com/repos/oracle/graalpython/releases'; + let url = getGraalPyReleasesUrl(); const result = []; do { const response = await http.getJson(url, headers); @@ -54921,8 +54932,7 @@ async function getAvailableGraalPyVersions() { /* Add pre-release builds. */ - url = - 'https://api.github.com/repos/graalvm/graal-languages-ea-builds/releases'; + url = getGraalPyPrereleaseReleasesUrl(); do { const response = await http.getJson(url, headers); if (!response.result) { @@ -55056,6 +55066,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { }; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.installPyPy = installPyPy; +exports.getPyPyVersionsUrl = getPyPyVersionsUrl; exports.getAvailablePyPyVersions = getAvailablePyPyVersions; exports.findRelease = findRelease; exports.pypyVersionToSemantic = pypyVersionToSemantic; @@ -55134,8 +55145,12 @@ async function installPyPy(pypyVersion, pythonVersion, architecture, allowPreRel throw err; } } +const DEFAULT_PYPY_VERSIONS_URL = 'https://downloads.python.org/pypy/versions.json'; +function getPyPyVersionsUrl() { + return core.getInput('pypy-versions-url') || DEFAULT_PYPY_VERSIONS_URL; +} async function getAvailablePyPyVersions() { - const url = 'https://downloads.python.org/pypy/versions.json'; + const url = getPyPyVersionsUrl(); const http = new httpm.HttpClient('tool-cache'); const response = await http.getJson(url); if (!response.result) { @@ -55265,6 +55280,7 @@ var __importStar = (this && this.__importStar) || (function () { })(); Object.defineProperty(exports, "__esModule", ({ value: true })); exports.MANIFEST_URL = void 0; +exports.getManifestUrl = getManifestUrl; exports.findReleaseFromManifest = findReleaseFromManifest; exports.getManifest = getManifest; exports.getManifestFromRepo = getManifestFromRepo; @@ -55278,10 +55294,29 @@ const httpm = __importStar(__nccwpck_require__(54844)); const utils_1 = __nccwpck_require__(71798); const TOKEN = core.getInput('token'); const AUTH = !TOKEN ? 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 DEFAULT_MANIFEST_REPO_OWNER = 'actions'; +const DEFAULT_MANIFEST_REPO_NAME = 'python-versions'; +const DEFAULT_MANIFEST_REPO_BRANCH = 'main'; +function getManifestRepoOwner() { + return core.getInput('manifest-repo-owner') || DEFAULT_MANIFEST_REPO_OWNER; +} +function getManifestRepoName() { + return core.getInput('manifest-repo-name') || DEFAULT_MANIFEST_REPO_NAME; +} +function getManifestRepoBranch() { + return core.getInput('manifest-repo-branch') || DEFAULT_MANIFEST_REPO_BRANCH; +} +function getManifestUrl() { + const override = core.getInput('manifest-url'); + if (override) { + return override; + } + return `https://raw.githubusercontent.com/${getManifestRepoOwner()}/${getManifestRepoName()}/${getManifestRepoBranch()}/versions-manifest.json`; +} +// Kept for backwards compatibility with consumers that import the constant. +// Note: this is evaluated at module load and does not reflect input overrides. +// Prefer getManifestUrl() in new code. +exports.MANIFEST_URL = getManifestUrl(); async function findReleaseFromManifest(semanticVersionSpec, architecture, manifest) { if (!manifest) { manifest = await getManifest(); @@ -55322,15 +55357,19 @@ async function getManifest() { return await getManifestFromURL(); } function getManifestFromRepo() { - core.debug(`Getting manifest from ${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}@${MANIFEST_REPO_BRANCH}`); - return tc.getManifestFromRepo(MANIFEST_REPO_OWNER, MANIFEST_REPO_NAME, AUTH, MANIFEST_REPO_BRANCH); + const owner = getManifestRepoOwner(); + const name = getManifestRepoName(); + const branch = getManifestRepoBranch(); + core.debug(`Getting manifest from ${owner}/${name}@${branch}`); + return tc.getManifestFromRepo(owner, name, AUTH, branch); } async function getManifestFromURL() { core.debug('Falling back to fetching the manifest using raw URL.'); + const manifestUrl = getManifestUrl(); const http = new httpm.HttpClient('tool-cache'); - const response = await http.getJson(exports.MANIFEST_URL); + const response = await http.getJson(manifestUrl); if (!response.result) { - throw new Error(`Unable to get manifest from ${exports.MANIFEST_URL}`); + throw new Error(`Unable to get manifest from ${manifestUrl}`); } return response.result; } diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 3f0996236..56a7c909c 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -544,6 +544,30 @@ Such a requirement on side-effect could be because you don't want your composite `setup-python` is able to download GraalPy versions from the [official GraalPy repository](https://github.com/oracle/graalpython). - All available versions that we can download are listed in [releases](https://github.com/oracle/graalpython/releases). +## Using a mirror for Python, PyPy or GraalPy + +For runners that cannot reach `github.com` or `downloads.python.org` directly (for example, behind a firewall or on a self-hosted Gitea instance), the source URLs that `setup-python` queries can be redirected to mirrors via action inputs. The mirror is expected to serve the same JSON shapes as the upstream sources, and the URLs embedded in the mirrored manifests should point to mirrored download targets as well. + +| Input | Default | Purpose | +| --- | --- | --- | +| `manifest-url` | `https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json` | Raw URL of the CPython `versions-manifest.json`. | +| `manifest-repo-owner` / `manifest-repo-name` / `manifest-repo-branch` | `actions` / `python-versions` / `main` | Used by the GitHub API fallback that fetches the manifest from a repository. | +| `pypy-versions-url` | `https://downloads.python.org/pypy/versions.json` | URL of the PyPy `versions.json`. | +| `graalpy-releases-url` | `https://api.github.com/repos/oracle/graalpython/releases` | Endpoint returning the GraalPy stable releases. | +| `graalpy-prerelease-releases-url` | `https://api.github.com/repos/graalvm/graal-languages-ea-builds/releases` | Endpoint returning the GraalPy pre-release builds. | + +```yaml +- uses: actions/setup-python@v6 + with: + python-version: '3.13' + manifest-url: 'https://gitea.example.com/mirror/python-versions/raw/branch/main/versions-manifest.json' + manifest-repo-owner: 'mirror' + manifest-repo-name: 'python-versions' + manifest-repo-branch: 'main' +``` + +> Only set the overrides you need. Each input falls back to the GitHub default when left empty, so a mirror that only handles CPython can leave the PyPy and GraalPy inputs unset. + ## Hosted tool cache GitHub hosted runners have a tool cache that comes with a few versions of Python + PyPy already installed. This tool 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 the tool cache with Python and PyPy installed. `setup-python` works by taking a specific version of Python or PyPy from this tool cache and adding it to PATH. diff --git a/src/find-python.ts b/src/find-python.ts index 99c6a7f26..fd27c5a23 100644 --- a/src/find-python.ts +++ b/src/find-python.ts @@ -137,7 +137,7 @@ export async function useCpythonVersion( ); } msg.push( - `The list of all available versions can be found here: ${installer.MANIFEST_URL}` + `The list of all available versions can be found here: ${installer.getManifestUrl()}` ); throw new Error(msg.join(os.EOL)); } diff --git a/src/install-graalpy.ts b/src/install-graalpy.ts index b1029539c..8bf50cf34 100644 --- a/src/install-graalpy.ts +++ b/src/install-graalpy.ts @@ -20,6 +20,21 @@ import { const TOKEN = core.getInput('token'); const AUTH = !TOKEN ? undefined : `token ${TOKEN}`; +const DEFAULT_GRAALPY_RELEASES_URL = + 'https://api.github.com/repos/oracle/graalpython/releases'; +const DEFAULT_GRAALPY_PRERELEASE_RELEASES_URL = + 'https://api.github.com/repos/graalvm/graal-languages-ea-builds/releases'; + +export function getGraalPyReleasesUrl(): string { + return core.getInput('graalpy-releases-url') || DEFAULT_GRAALPY_RELEASES_URL; +} + +export function getGraalPyPrereleaseReleasesUrl(): string { + return ( + core.getInput('graalpy-prerelease-releases-url') || + DEFAULT_GRAALPY_PRERELEASE_RELEASES_URL + ); +} export async function installGraalPy( graalpyVersion: string, @@ -121,8 +136,7 @@ export async function getAvailableGraalPyVersions() { /* Get releases first. */ - let url: string | null = - 'https://api.github.com/repos/oracle/graalpython/releases'; + let url: string | null = getGraalPyReleasesUrl(); const result: IGraalPyManifestRelease[] = []; do { const response: ifm.TypedResponse = @@ -139,8 +153,7 @@ export async function getAvailableGraalPyVersions() { /* Add pre-release builds. */ - url = - 'https://api.github.com/repos/graalvm/graal-languages-ea-builds/releases'; + url = getGraalPyPrereleaseReleasesUrl(); do { const response: ifm.TypedResponse = await http.getJson(url, headers); diff --git a/src/install-pypy.ts b/src/install-pypy.ts index 405f9f60c..34e0f3d59 100644 --- a/src/install-pypy.ts +++ b/src/install-pypy.ts @@ -124,8 +124,15 @@ export async function installPyPy( } } +const DEFAULT_PYPY_VERSIONS_URL = + 'https://downloads.python.org/pypy/versions.json'; + +export function getPyPyVersionsUrl(): string { + return core.getInput('pypy-versions-url') || DEFAULT_PYPY_VERSIONS_URL; +} + export async function getAvailablePyPyVersions() { - const url = 'https://downloads.python.org/pypy/versions.json'; + const url = getPyPyVersionsUrl(); const http: httpm.HttpClient = new httpm.HttpClient('tool-cache'); const response = await http.getJson(url); diff --git a/src/install-python.ts b/src/install-python.ts index bef0161c3..8dc837973 100644 --- a/src/install-python.ts +++ b/src/install-python.ts @@ -9,10 +9,34 @@ import {IToolRelease} from '@actions/tool-cache'; const TOKEN = core.getInput('token'); const AUTH = !TOKEN ? undefined : `token ${TOKEN}`; -const MANIFEST_REPO_OWNER = 'actions'; -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 DEFAULT_MANIFEST_REPO_OWNER = 'actions'; +const DEFAULT_MANIFEST_REPO_NAME = 'python-versions'; +const DEFAULT_MANIFEST_REPO_BRANCH = 'main'; + +function getManifestRepoOwner(): string { + return core.getInput('manifest-repo-owner') || DEFAULT_MANIFEST_REPO_OWNER; +} + +function getManifestRepoName(): string { + return core.getInput('manifest-repo-name') || DEFAULT_MANIFEST_REPO_NAME; +} + +function getManifestRepoBranch(): string { + return core.getInput('manifest-repo-branch') || DEFAULT_MANIFEST_REPO_BRANCH; +} + +export function getManifestUrl(): string { + const override = core.getInput('manifest-url'); + if (override) { + return override; + } + return `https://raw.githubusercontent.com/${getManifestRepoOwner()}/${getManifestRepoName()}/${getManifestRepoBranch()}/versions-manifest.json`; +} + +// Kept for backwards compatibility with consumers that import the constant. +// Note: this is evaluated at module load and does not reflect input overrides. +// Prefer getManifestUrl() in new code. +export const MANIFEST_URL = getManifestUrl(); export async function findReleaseFromManifest( semanticVersionSpec: string, @@ -73,24 +97,21 @@ export async function getManifest(): Promise { } export function getManifestFromRepo(): Promise { - core.debug( - `Getting manifest from ${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}@${MANIFEST_REPO_BRANCH}` - ); - return tc.getManifestFromRepo( - MANIFEST_REPO_OWNER, - MANIFEST_REPO_NAME, - AUTH, - MANIFEST_REPO_BRANCH - ); + const owner = getManifestRepoOwner(); + const name = getManifestRepoName(); + const branch = getManifestRepoBranch(); + core.debug(`Getting manifest from ${owner}/${name}@${branch}`); + return tc.getManifestFromRepo(owner, name, AUTH, branch); } export async function getManifestFromURL(): Promise { core.debug('Falling back to fetching the manifest using raw URL.'); + const manifestUrl = getManifestUrl(); const http: httpm.HttpClient = new httpm.HttpClient('tool-cache'); - const response = await http.getJson(MANIFEST_URL); + const response = await http.getJson(manifestUrl); if (!response.result) { - throw new Error(`Unable to get manifest from ${MANIFEST_URL}`); + throw new Error(`Unable to get manifest from ${manifestUrl}`); } return response.result; }