From f30f2fee26873e08aa2a5078e6dab77e3a0907a9 Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Mon, 6 Apr 2026 00:59:40 +0200 Subject: [PATCH 1/2] feat: Add `mirror` and `mirror-token` inputs for custom Python distribution sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users who need custom CPython builds (internal mirrors, GHES-hosted forks, special build configurations, compliance builds, air-gapped runners) could not previously point setup-python at anything other than actions/python-versions. Adds two new inputs: - `mirror`: base URL hosting versions-manifest.json and the Python distributions it references. Defaults to the existing https://raw.githubusercontent.com/actions/python-versions/main. - `mirror-token`: optional token used to authenticate requests to the mirror. If `mirror` is a raw.githubusercontent.com/{owner}/{repo}/{branch} URL, the manifest is fetched via the GitHub REST API (authenticated rate limit applies); otherwise the action falls back to a direct GET of {mirror}/versions-manifest.json. Token interaction ----------------- `token` is never forwarded to arbitrary hosts. Auth resolution is per-URL: 1. if mirror-token is set, use mirror-token 2. else if token is set AND the target host is github.com, *.github.com, or *.githubusercontent.com, use token 3. else send no auth Cases: Default (no inputs set) mirror = default raw.githubusercontent.com URL, mirror-token empty, token = github.token. → manifest API call and tarball downloads use `token`. Identical to prior behavior. Custom raw.githubusercontent.com mirror (e.g. personal fork) mirror-token empty, token = github.token. → manifest API call and tarball downloads use `token` (target hosts are GitHub-owned). Custom non-GitHub mirror, no mirror-token mirror-token empty, token = github.token. → manifest fetched via direct URL (no auth attached), tarball downloads use no auth. `token` is NOT forwarded to the custom host — this is the leak-prevention case. Custom non-GitHub mirror with mirror-token mirror-token set, token may be set. → manifest fetch and tarball downloads use `mirror-token`. Custom GitHub mirror with both tokens set mirror-token wins. Used for both the manifest API call and tarball downloads. --- .github/workflows/test-python.yml | 20 ++ __tests__/install-python-mirror.test.ts | 337 ++++++++++++++++++++++++ action.yml | 8 +- dist/setup/index.js | 91 +++++-- docs/advanced-usage.md | 35 +++ src/find-python.ts | 2 +- src/install-python.ts | 101 +++++-- 7 files changed, 559 insertions(+), 35 deletions(-) create mode 100644 __tests__/install-python-mirror.test.ts diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 78d48cabc..566ed2c30 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -61,6 +61,26 @@ jobs: - name: Run simple code run: python -c 'import math; print(math.factorial(5))' + setup-versions-via-mirror-input: + name: 'Setup via explicit mirror input: ${{ matrix.os }}' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: setup-python with explicit mirror + uses: ./ + with: + python-version: 3.12 + mirror: https://raw.githubusercontent.com/actions/python-versions/main + + - name: Run simple code + run: python -c 'import sys; print(sys.version)' + setup-versions-from-file: name: Setup ${{ matrix.python }} ${{ matrix.os }} version file runs-on: ${{ matrix.os }} diff --git a/__tests__/install-python-mirror.test.ts b/__tests__/install-python-mirror.test.ts new file mode 100644 index 000000000..4bb5022d5 --- /dev/null +++ b/__tests__/install-python-mirror.test.ts @@ -0,0 +1,337 @@ +import {jest, describe, it, expect, beforeEach} from '@jest/globals'; + +// Inputs are read lazily by install-python.ts, so each test can set them +// before invoking the function under test. +const inputs: Record = {}; + +// Mock @actions/http-client +jest.unstable_mockModule('@actions/http-client', () => ({ + HttpClient: jest.fn().mockImplementation(() => ({ + getJson: jest.fn() + })), + HttpClientError: class HttpClientError extends Error {}, + HttpCodes: { + OK: 200, + NotFound: 404, + InternalServerError: 500 + } +})); + +// Mock @actions/cache (needed transitively by utils.ts) +jest.unstable_mockModule('@actions/cache', () => ({ + saveCache: jest.fn(), + restoreCache: jest.fn(), + isFeatureAvailable: jest.fn() +})); + +// Mock @actions/tool-cache +jest.unstable_mockModule('@actions/tool-cache', () => ({ + getManifestFromRepo: jest.fn(), + downloadTool: jest.fn(), + extractTar: jest.fn(), + extractZip: jest.fn(), + HTTPError: class HTTPError extends Error {} +})); + +// Mock @actions/core (needed by install-python.ts) +jest.unstable_mockModule('@actions/core', () => ({ + info: jest.fn(), + warning: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + notice: jest.fn(), + setFailed: jest.fn(), + setOutput: jest.fn(), + getInput: jest.fn(), + getBooleanInput: jest.fn(), + getMultilineInput: jest.fn(), + addPath: jest.fn(), + exportVariable: jest.fn(), + saveState: jest.fn(), + getState: jest.fn(), + setSecret: jest.fn(), + isDebug: jest.fn(() => false), + startGroup: jest.fn(), + endGroup: jest.fn(), + group: jest.fn((_name: string, fn: () => Promise) => fn()), + toPlatformPath: jest.fn((p: string) => p), + toWin32Path: jest.fn((p: string) => p), + toPosixPath: jest.fn((p: string) => p) +})); + +// Mock @actions/exec (needed by install-python.ts) +jest.unstable_mockModule('@actions/exec', () => ({ + exec: jest.fn(), + getExecOutput: jest.fn() +})); + +// Import real utils BEFORE mock registration to get real function references +const realUtils = await import('../src/utils.js'); + +// Pin the platform so the download/extract assertions below behave the same +// on every runner OS. +jest.unstable_mockModule('../src/utils.js', () => ({ + ...realUtils, + IS_WINDOWS: false, + IS_LINUX: false +})); + +// Dynamic imports after mocking +const core = await import('@actions/core'); +const httpm = await import('@actions/http-client'); +const tc = await import('@actions/tool-cache'); +const { + getManifestUrl, + getManifestFromRepo, + getManifestFromURL, + installCpythonFromRelease +} = await import('../src/install-python.js'); + +const DEFAULT_MIRROR = + 'https://raw.githubusercontent.com/actions/python-versions/main'; + +const mockManifest = [ + { + version: '1.0.0', + stable: true, + files: [ + { + filename: 'tool-v1.0.0-linux-x64.tar.gz', + platform: 'linux', + arch: 'x64', + download_url: 'https://example.com/tool-v1.0.0-linux-x64.tar.gz' + } + ] + } +]; + +function setInputs(values: Record) { + Object.assign(inputs, values); +} + +beforeEach(() => { + jest.resetAllMocks(); + for (const key of Object.keys(inputs)) { + delete inputs[key]; + } + (core.getInput as jest.Mock).mockImplementation( + (name: string) => inputs[name] ?? '' + ); +}); + +describe('getManifestUrl', () => { + it('defaults to the actions/python-versions manifest', () => { + expect(getManifestUrl()).toBe(`${DEFAULT_MIRROR}/versions-manifest.json`); + }); + + it('appends versions-manifest.json to a custom mirror', () => { + setInputs({mirror: 'https://mirror.example/py'}); + expect(getManifestUrl()).toBe( + 'https://mirror.example/py/versions-manifest.json' + ); + }); + + it('strips trailing slashes from the mirror', () => { + setInputs({mirror: 'https://mirror.example/py///'}); + expect(getManifestUrl()).toBe( + 'https://mirror.example/py/versions-manifest.json' + ); + }); + + it('throws on a mirror that is not a valid URL', () => { + setInputs({mirror: 'not a url'}); + expect(() => getManifestUrl()).toThrow(/Invalid 'mirror' URL/); + }); +}); + +describe('getManifestFromRepo mirror resolution', () => { + it('resolves the default mirror to actions/python-versions@main with token', async () => { + setInputs({token: 'TKN'}); + (tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest); + + await getManifestFromRepo(); + + expect(tc.getManifestFromRepo).toHaveBeenCalledWith( + 'actions', + 'python-versions', + 'token TKN', + 'main' + ); + }); + + it('extracts owner/repo/branch from a custom raw.githubusercontent.com mirror', async () => { + setInputs({ + token: 'TKN', + mirror: 'https://raw.githubusercontent.com/foo/bar/dev' + }); + (tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest); + + await getManifestFromRepo(); + + expect(tc.getManifestFromRepo).toHaveBeenCalledWith( + 'foo', + 'bar', + 'token TKN', + 'dev' + ); + }); + + it('strips a trailing slash before extracting the branch', async () => { + setInputs({ + token: 'TKN', + mirror: 'https://raw.githubusercontent.com/foo/bar/main/' + }); + (tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest); + + await getManifestFromRepo(); + + expect(tc.getManifestFromRepo).toHaveBeenCalledWith( + 'foo', + 'bar', + 'token TKN', + 'main' + ); + }); + + it('throws for a non-GitHub mirror so the caller falls back to the raw URL', () => { + setInputs({mirror: 'https://mirror.example/py'}); + expect(() => getManifestFromRepo()).toThrow(/not a GitHub repo URL/); + expect(tc.getManifestFromRepo).not.toHaveBeenCalled(); + }); + + it('prefers mirror-token over token for the GitHub API call', async () => { + setInputs({ + token: 'TKN', + 'mirror-token': 'MTOK', + mirror: 'https://raw.githubusercontent.com/foo/bar/main' + }); + (tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest); + + await getManifestFromRepo(); + + expect(tc.getManifestFromRepo).toHaveBeenCalledWith( + 'foo', + 'bar', + 'token MTOK', + 'main' + ); + }); + + it('sends no auth when neither token nor mirror-token is set', async () => { + (tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest); + + await getManifestFromRepo(); + + expect(tc.getManifestFromRepo).toHaveBeenCalledWith( + 'actions', + 'python-versions', + undefined, + 'main' + ); + }); +}); + +describe('getManifestFromURL mirror resolution', () => { + it('fetches {mirror}/versions-manifest.json without attaching auth', async () => { + setInputs({token: 'TKN', mirror: 'https://mirror.example/py'}); + const getJson = jest.fn(async () => ({result: mockManifest})); + (httpm.HttpClient as jest.Mock).mockImplementation(() => ({getJson})); + + await getManifestFromURL(); + + expect(getJson).toHaveBeenCalledWith( + 'https://mirror.example/py/versions-manifest.json' + ); + }); +}); + +describe('installCpythonFromRelease auth gating', () => { + const makeRelease = (downloadUrl: string) => + ({ + version: '3.12.0', + stable: true, + release_url: '', + files: [ + { + filename: 'python-3.12.0-linux-x64.tar.gz', + platform: 'linux', + platform_version: '', + arch: 'x64', + download_url: downloadUrl + } + ] + }) as any; + + // Returns the auth argument tc.downloadTool was called with. + async function downloadAuthFor(downloadUrl: string) { + (tc.downloadTool as jest.Mock).mockResolvedValue('/tmp/py.tgz'); + (tc.extractTar as jest.Mock).mockResolvedValue('/tmp/extracted'); + + await installCpythonFromRelease(makeRelease(downloadUrl)); + + const call = (tc.downloadTool as jest.Mock).mock.calls[0]; + expect(call[0]).toBe(downloadUrl); + return call[2]; + } + + it('forwards token to github.com download URLs', async () => { + setInputs({token: 'TKN'}); + await expect( + downloadAuthFor( + 'https://github.com/actions/python-versions/releases/download/3.12.0-x/python-3.12.0-linux-x64.tar.gz' + ) + ).resolves.toBe('token TKN'); + }); + + it('forwards token to api.github.com download URLs', async () => { + setInputs({token: 'TKN'}); + await expect( + downloadAuthFor('https://api.github.com/repos/x/y/tarball/main') + ).resolves.toBe('token TKN'); + }); + + it('forwards token to *.githubusercontent.com download URLs', async () => { + setInputs({token: 'TKN'}); + await expect( + downloadAuthFor('https://objects.githubusercontent.com/x/python.tar.gz') + ).resolves.toBe('token TKN'); + }); + + it('does NOT forward token to a non-GitHub download URL', async () => { + setInputs({token: 'TKN', mirror: 'https://cdn.example'}); + await expect( + downloadAuthFor('https://cdn.example/py.tar.gz') + ).resolves.toBeUndefined(); + }); + + it('does NOT forward token to a lookalike host', async () => { + setInputs({token: 'TKN', mirror: 'https://evil-github.com'}); + await expect( + downloadAuthFor('https://evil-github.com/py.tar.gz') + ).resolves.toBeUndefined(); + }); + + it('forwards mirror-token to a non-GitHub download URL', async () => { + setInputs({ + token: 'TKN', + 'mirror-token': 'MTOK', + mirror: 'https://cdn.example' + }); + await expect( + downloadAuthFor('https://cdn.example/py.tar.gz') + ).resolves.toBe('token MTOK'); + }); + + it('prefers mirror-token over token for GitHub download URLs', async () => { + setInputs({token: 'TKN', 'mirror-token': 'MTOK'}); + await expect( + downloadAuthFor('https://github.com/o/r/releases/download/v/py.tar.gz') + ).resolves.toBe('token MTOK'); + }); + + it('sends no auth when no tokens are configured', async () => { + await expect( + downloadAuthFor('https://github.com/o/r/releases/download/v/py.tar.gz') + ).resolves.toBeUndefined(); + }); +}); diff --git a/action.yml b/action.yml index df6c8235b..314ba2198 100644 --- a/action.yml +++ b/action.yml @@ -16,8 +16,14 @@ inputs: description: "Set this option if you want the action to check for the latest available version that satisfies the version spec." default: false token: - description: "The token used to authenticate when fetching Python distributions from https://github.com/actions/python-versions. When running this action on github.com, the default value is sufficient. When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting." + description: "The token used to authenticate when fetching Python distributions from https://github.com/actions/python-versions. When running this action on github.com, the default value is sufficient. When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting. When 'mirror-token' is set, it takes precedence over this input." default: ${{ github.server_url == 'https://github.com' && github.token || '' }} + mirror: + description: "Base URL for downloading Python distributions. Defaults to https://raw.githubusercontent.com/actions/python-versions/main. See docs/advanced-usage.md for details." + default: "https://raw.githubusercontent.com/actions/python-versions/main" + mirror-token: + description: "Token used to authenticate requests to 'mirror'. Takes precedence over 'token'." + required: false cache-dependency-path: description: "Used to specify the path to dependency files. Supports wildcards or a list of file names for caching multiple dependencies." update-environment: diff --git a/dist/setup/index.js b/dist/setup/index.js index 16fe5c6f2..cd2504d0d 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -98693,12 +98693,56 @@ function _unique(values) { -const TOKEN = getInput('token'); -const AUTH = !TOKEN ? undefined : `token ${TOKEN}`; -const MANIFEST_REPO_OWNER = 'actions'; -const MANIFEST_REPO_NAME = 'python-versions'; -const MANIFEST_REPO_BRANCH = 'main'; -const MANIFEST_URL = `https://raw.githubusercontent.com/${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}/${MANIFEST_REPO_BRANCH}/versions-manifest.json`; +const DEFAULT_REPO_OWNER = 'actions'; +const DEFAULT_REPO_NAME = 'python-versions'; +const DEFAULT_REPO_BRANCH = 'main'; +const DEFAULT_MIRROR = `https://raw.githubusercontent.com/${DEFAULT_REPO_OWNER}/${DEFAULT_REPO_NAME}/${DEFAULT_REPO_BRANCH}`; +// Matches https://raw.githubusercontent.com/{owner}/{repo}/{branch} +const REPO_COORDS_RE = /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/?$/; +function getToken() { + return getInput('token'); +} +function getMirrorToken() { + return getInput('mirror-token'); +} +function getMirror() { + const raw = (getInput('mirror') || DEFAULT_MIRROR) + .trim() + .replace(/\/+$/, ''); + try { + new URL(raw); + } + catch { + throw new Error(`Invalid 'mirror' URL: "${raw}"`); + } + return raw; +} +function getManifestUrl() { + return `${getMirror()}/versions-manifest.json`; +} +function resolveRepoCoords() { + const m = REPO_COORDS_RE.exec(getMirror()); + return m ? { owner: m[1], repo: m[2], branch: m[3] } : null; +} +function authForUrl(url) { + const mirrorToken = getMirrorToken(); + if (mirrorToken) + return `token ${mirrorToken}`; + let host; + try { + host = new URL(url).host; + } + catch { + return undefined; + } + const token = getToken(); + if (token && + (host === 'github.com' || + host.endsWith('.github.com') || + host.endsWith('.githubusercontent.com'))) + return `token ${token}`; + return undefined; +} function getLinuxOsRelease() { try { const content = external_fs_namespaceObject.readFileSync('/etc/os-release', 'utf8'); @@ -98846,15 +98890,28 @@ async function getManifest() { } } function install_python_getManifestFromRepo() { - core_debug(`Getting manifest from ${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}@${MANIFEST_REPO_BRANCH}`); - return getManifestFromRepo(MANIFEST_REPO_OWNER, MANIFEST_REPO_NAME, AUTH, MANIFEST_REPO_BRANCH); + const coords = resolveRepoCoords(); + if (!coords) { + throw new Error(`Mirror "${getMirror()}" is not a GitHub repo URL; falling back to raw URL fetch.`); + } + core_debug(`Getting manifest from ${coords.owner}/${coords.repo}@${coords.branch}`); + // api.github.com is a GitHub-owned URL. Prefer MIRROR_TOKEN (the user provided token), fall back to TOKEN. + const token = getToken(); + const mirrorToken = getMirrorToken(); + const auth = !mirrorToken + ? !token + ? undefined + : `token ${token}` + : `token ${mirrorToken}`; + return getManifestFromRepo(coords.owner, coords.repo, auth, coords.branch); } async function getManifestFromURL() { core_debug('Falling back to fetching the manifest using raw URL.'); + const manifestUrl = getManifestUrl(); const http = new lib_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; } @@ -98897,7 +98954,7 @@ async function installCpythonFromRelease(release) { let pythonPath = ''; try { const fileName = getDownloadFileName(downloadUrl); - pythonPath = await downloadTool(downloadUrl, fileName, AUTH); + pythonPath = await downloadTool(downloadUrl, fileName, authForUrl(downloadUrl)); info('Extract downloaded archive'); let pythonExtractedFolder; if (utils_IS_WINDOWS) { @@ -99015,7 +99072,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: ${MANIFEST_URL}`); + msg.push(`The list of all available versions can be found here: ${getManifestUrl()}`); throw new Error(msg.join(external_os_.EOL)); } const _binDir = binDir(installDir); @@ -99417,8 +99474,8 @@ function findPyPyInstallDirForWindows(pythonVersion) { -const install_graalpy_TOKEN = getInput('token'); -const install_graalpy_AUTH = !install_graalpy_TOKEN ? undefined : `token ${install_graalpy_TOKEN}`; +const TOKEN = getInput('token'); +const AUTH = !TOKEN ? undefined : `token ${TOKEN}`; async function installGraalPy(graalpyVersion, architecture, allowPreReleases, releases) { let downloadDir; releases = releases ?? (await getAvailableGraalPyVersions()); @@ -99441,7 +99498,7 @@ async function installGraalPy(graalpyVersion, architecture, allowPreReleases, re const downloadUrl = `${foundAsset.browser_download_url}`; info(`Downloading GraalPy from "${downloadUrl}" ...`); try { - const graalpyPath = await downloadTool(downloadUrl, undefined, install_graalpy_AUTH); + const graalpyPath = await downloadTool(downloadUrl, undefined, AUTH); info('Extracting downloaded archive...'); if (utils_IS_WINDOWS) { downloadDir = await extractZip(graalpyPath); @@ -99482,8 +99539,8 @@ async function installGraalPy(graalpyVersion, architecture, allowPreReleases, re async function getAvailableGraalPyVersions() { const http = new lib_HttpClient('tool-cache'); const headers = {}; - if (install_graalpy_AUTH) { - headers.authorization = install_graalpy_AUTH; + if (AUTH) { + headers.authorization = AUTH; } /* Get releases first. diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 1c17dbfd2..e75c22464 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -524,6 +524,41 @@ Such a requirement on side-effect could be because you don't want your composite >**Note:** Python versions used in this action are generated in the [python-versions](https://github.com/actions/python-versions) repository. For macOS and Ubuntu images, python versions are built from the source code. For Windows, the python-versions repository uses installation executable. For more information please refer to the [python-versions](https://github.com/actions/python-versions) repository. +#### Using a custom mirror + +The `mirror` input lets you point `setup-python` at a different location for CPython distributions — a personal fork of `actions/python-versions`, an internal mirror, or any server that hosts a `versions-manifest.json` at its root plus the tarballs referenced by that manifest. Default: `https://raw.githubusercontent.com/actions/python-versions/main`. + +The manifest is resolved as follows: + +- If `mirror` matches `https://raw.githubusercontent.com/{owner}/{repo}/{branch}`, the manifest is fetched via the GitHub REST API (giving you the 5000/hr authenticated rate limit when a token is present). +- Otherwise, the action fetches `{mirror}/versions-manifest.json` via a direct HTTP GET. + +Authentication: + +- `token` is forwarded **only** to `github.com` and hosts under `*.github.com` or `*.githubusercontent.com`. It is never sent to a custom mirror. +- `mirror-token` takes precedence over `token`: if `mirror-token` is set it is used for every authenticated request (manifest fetch and tarball downloads). +- If `mirror-token` is empty, `token` is used when the target URL is GitHub-owned. +- If neither applies, requests are anonymous. + +Point at a personal fork of `actions/python-versions` (uses the default `token`, fetched via the GitHub API): + +```yaml +- uses: actions/setup-python@v6 + with: + python-version: '3.12' + mirror: https://raw.githubusercontent.com/my-org/python-versions/main +``` + +Point at an internal mirror with its own credential: + +```yaml +- uses: actions/setup-python@v6 + with: + python-version: '3.12' + mirror: https://python-mirror.internal.example + mirror-token: ${{ secrets.PYTHON_MIRROR_TOKEN }} +``` + ### PyPy `setup-python` is able to configure **PyPy** from two sources: diff --git a/src/find-python.ts b/src/find-python.ts index 665188887..dbb7ed2dc 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-python.ts b/src/install-python.ts index c787def70..8870bc963 100644 --- a/src/install-python.ts +++ b/src/install-python.ts @@ -9,12 +9,67 @@ import * as semver from 'semver'; import {IS_WINDOWS, IS_LINUX, getDownloadFileName} from './utils.js'; 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_REPO_OWNER = 'actions'; +const DEFAULT_REPO_NAME = 'python-versions'; +const DEFAULT_REPO_BRANCH = 'main'; +const DEFAULT_MIRROR = `https://raw.githubusercontent.com/${DEFAULT_REPO_OWNER}/${DEFAULT_REPO_NAME}/${DEFAULT_REPO_BRANCH}`; + +// Matches https://raw.githubusercontent.com/{owner}/{repo}/{branch} +const REPO_COORDS_RE = + /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/?$/; + +function getToken(): string { + return core.getInput('token'); +} + +function getMirrorToken(): string { + return core.getInput('mirror-token'); +} + +function getMirror(): string { + const raw = (core.getInput('mirror') || DEFAULT_MIRROR) + .trim() + .replace(/\/+$/, ''); + try { + new URL(raw); + } catch { + throw new Error(`Invalid 'mirror' URL: "${raw}"`); + } + return raw; +} + +export function getManifestUrl(): string { + return `${getMirror()}/versions-manifest.json`; +} + +function resolveRepoCoords(): { + owner: string; + repo: string; + branch: string; +} | null { + const m = REPO_COORDS_RE.exec(getMirror()); + return m ? {owner: m[1], repo: m[2], branch: m[3]} : null; +} + +function authForUrl(url: string): string | undefined { + const mirrorToken = getMirrorToken(); + if (mirrorToken) return `token ${mirrorToken}`; + let host: string; + try { + host = new URL(url).host; + } catch { + return undefined; + } + const token = getToken(); + if ( + token && + (host === 'github.com' || + host.endsWith('.github.com') || + host.endsWith('.githubusercontent.com')) + ) + return `token ${token}`; + return undefined; +} interface LinuxOsRelease { id: string; @@ -229,24 +284,34 @@ export async function getManifest(): Promise { } export function getManifestFromRepo(): Promise { + const coords = resolveRepoCoords(); + if (!coords) { + throw new Error( + `Mirror "${getMirror()}" is not a GitHub repo URL; falling back to raw URL fetch.` + ); + } 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 + `Getting manifest from ${coords.owner}/${coords.repo}@${coords.branch}` ); + // api.github.com is a GitHub-owned URL. Prefer MIRROR_TOKEN (the user provided token), fall back to TOKEN. + const token = getToken(); + const mirrorToken = getMirrorToken(); + const auth = !mirrorToken + ? !token + ? undefined + : `token ${token}` + : `token ${mirrorToken}`; + return tc.getManifestFromRepo(coords.owner, coords.repo, auth, coords.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; } @@ -291,7 +356,11 @@ export async function installCpythonFromRelease(release: tc.IToolRelease) { let pythonPath = ''; try { const fileName = getDownloadFileName(downloadUrl); - pythonPath = await tc.downloadTool(downloadUrl, fileName, AUTH); + pythonPath = await tc.downloadTool( + downloadUrl, + fileName, + authForUrl(downloadUrl) + ); core.info('Extract downloaded archive'); let pythonExtractedFolder; if (IS_WINDOWS) { From 0d1135ac814405808323d2060134097ba5fd641b Mon Sep 17 00:00:00 2001 From: Ludovic Henry Date: Fri, 14 Aug 2026 18:10:29 +0200 Subject: [PATCH 2/2] fix: address mirror review feedback - scope mirror-token to the mirror host and send it verbatim - route non-repo mirrors straight to the URL fetch instead of throwing - authenticate the manifest fetch - warn on slash branches, and on mirror with PyPy/GraalPy - memoize mirror validation - exercise the direct-URL path in the E2E job Addresses https://github.com/actions/setup-python/pull/1302#issuecomment-5202618946 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/test-python.yml | 5 +- __tests__/install-python-mirror.test.ts | 159 ++++++++++++++++++++++-- action.yml | 6 +- dist/setup/index.js | 130 ++++++++++++++----- docs/advanced-usage.md | 15 ++- src/install-python.ts | 137 ++++++++++++++------ src/setup-python.ts | 15 +++ 7 files changed, 380 insertions(+), 87 deletions(-) diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 566ed2c30..bfc005bf9 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -72,11 +72,14 @@ jobs: - name: Checkout uses: actions/checkout@v6 + # The refs/heads/ form serves the same manifest as the default mirror but + # deliberately does not match {owner}/{repo}/{branch}, so this exercises + # the direct-URL manifest fetch that the default coordinates skip. - name: setup-python with explicit mirror uses: ./ with: python-version: 3.12 - mirror: https://raw.githubusercontent.com/actions/python-versions/main + mirror: https://raw.githubusercontent.com/actions/python-versions/refs/heads/main - name: Run simple code run: python -c 'import sys; print(sys.version)' diff --git a/__tests__/install-python-mirror.test.ts b/__tests__/install-python-mirror.test.ts index 4bb5022d5..6a0854f9d 100644 --- a/__tests__/install-python-mirror.test.ts +++ b/__tests__/install-python-mirror.test.ts @@ -82,8 +82,10 @@ const httpm = await import('@actions/http-client'); const tc = await import('@actions/tool-cache'); const { getManifestUrl, + getManifest, getManifestFromRepo, getManifestFromURL, + resolveRepoCoords, installCpythonFromRelease } = await import('../src/install-python.js'); @@ -142,6 +144,34 @@ describe('getManifestUrl', () => { setInputs({mirror: 'not a url'}); expect(() => getManifestUrl()).toThrow(/Invalid 'mirror' URL/); }); + + it('keeps throwing the same error when called repeatedly', () => { + setInputs({mirror: 'not a url'}); + expect(() => getManifestUrl()).toThrow(/Invalid 'mirror' URL/); + // Memoized, so the second call must not silently succeed or change shape — + // find-python.ts calls this while building the "version not found" message. + expect(() => getManifestUrl()).toThrow(/Invalid 'mirror' URL/); + }); +}); + +describe('resolveRepoCoords', () => { + it('warns and returns null for a raw.githubusercontent.com mirror with a slash in the branch', () => { + setInputs({ + mirror: 'https://raw.githubusercontent.com/foo/bar/feature/riscv' + }); + + expect(resolveRepoCoords()).toBeNull(); + expect(core.warning).toHaveBeenCalledWith( + expect.stringMatching(/Branch names containing '\/' are not supported/) + ); + }); + + it('does not warn for a non-GitHub mirror', () => { + setInputs({mirror: 'https://mirror.example/py'}); + + expect(resolveRepoCoords()).toBeNull(); + expect(core.warning).not.toHaveBeenCalled(); + }); }); describe('getManifestFromRepo mirror resolution', () => { @@ -193,9 +223,9 @@ describe('getManifestFromRepo mirror resolution', () => { ); }); - it('throws for a non-GitHub mirror so the caller falls back to the raw URL', () => { + it('returns null for a non-GitHub mirror so the caller uses the raw URL', () => { setInputs({mirror: 'https://mirror.example/py'}); - expect(() => getManifestFromRepo()).toThrow(/not a GitHub repo URL/); + expect(resolveRepoCoords()).toBeNull(); expect(tc.getManifestFromRepo).not.toHaveBeenCalled(); }); @@ -209,6 +239,8 @@ describe('getManifestFromRepo mirror resolution', () => { await getManifestFromRepo(); + // The API requires the `token ` prefix, and naming a repo mirror is explicit intent to + // read that repo, so mirror-token is prefixed here even though downloads send it verbatim. expect(tc.getManifestFromRepo).toHaveBeenCalledWith( 'foo', 'bar', @@ -232,19 +264,81 @@ describe('getManifestFromRepo mirror resolution', () => { }); describe('getManifestFromURL mirror resolution', () => { - it('fetches {mirror}/versions-manifest.json without attaching auth', async () => { + it('fetches {mirror}/versions-manifest.json without auth when no mirror-token is set', async () => { setInputs({token: 'TKN', mirror: 'https://mirror.example/py'}); const getJson = jest.fn(async () => ({result: mockManifest})); (httpm.HttpClient as jest.Mock).mockImplementation(() => ({getJson})); await getManifestFromURL(); + // `token` must not reach a non-GitHub mirror. expect(getJson).toHaveBeenCalledWith( - 'https://mirror.example/py/versions-manifest.json' + 'https://mirror.example/py/versions-manifest.json', + undefined + ); + }); + + it('sends mirror-token verbatim on the manifest fetch', async () => { + setInputs({ + token: 'TKN', + 'mirror-token': 'Bearer MTOK', + mirror: 'https://mirror.example/py' + }); + const getJson = jest.fn(async () => ({result: mockManifest})); + (httpm.HttpClient as jest.Mock).mockImplementation(() => ({getJson})); + + await getManifestFromURL(); + + expect(getJson).toHaveBeenCalledWith( + 'https://mirror.example/py/versions-manifest.json', + {authorization: 'Bearer MTOK'} + ); + }); + + it('sends token as a prefixed header for a GitHub-hosted raw manifest', async () => { + setInputs({ + token: 'TKN', + mirror: 'https://raw.githubusercontent.com/foo/bar/refs/heads/main' + }); + const getJson = jest.fn(async () => ({result: mockManifest})); + (httpm.HttpClient as jest.Mock).mockImplementation(() => ({getJson})); + + await getManifestFromURL(); + + expect(getJson).toHaveBeenCalledWith( + 'https://raw.githubusercontent.com/foo/bar/refs/heads/main/versions-manifest.json', + {authorization: 'token TKN'} ); }); }); +describe('getManifest source routing', () => { + it('skips the GitHub API entirely for a non-GitHub mirror', async () => { + setInputs({mirror: 'https://mirror.example/py'}); + const getJson = jest.fn(async () => ({result: mockManifest})); + (httpm.HttpClient as jest.Mock).mockImplementation(() => ({getJson})); + + await expect(getManifest()).resolves.toEqual(mockManifest); + + // Routing straight to the URL fetch avoids 3 retries with backoff on a + // call that could never succeed. + expect(tc.getManifestFromRepo).not.toHaveBeenCalled(); + expect(getJson).toHaveBeenCalledTimes(1); + }); + + it('uses the GitHub API for a repo mirror without touching the raw URL', async () => { + setInputs({token: 'TKN'}); + (tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest); + const getJson = jest.fn(async () => ({result: mockManifest})); + (httpm.HttpClient as jest.Mock).mockImplementation(() => ({getJson})); + + await expect(getManifest()).resolves.toEqual(mockManifest); + + expect(tc.getManifestFromRepo).toHaveBeenCalledTimes(1); + expect(getJson).not.toHaveBeenCalled(); + }); +}); + describe('installCpythonFromRelease auth gating', () => { const makeRelease = (downloadUrl: string) => ({ @@ -311,21 +405,66 @@ describe('installCpythonFromRelease auth gating', () => { ).resolves.toBeUndefined(); }); - it('forwards mirror-token to a non-GitHub download URL', async () => { + it('forwards mirror-token verbatim to the mirror host', async () => { setInputs({ token: 'TKN', - 'mirror-token': 'MTOK', + 'mirror-token': 'Bearer MTOK', mirror: 'https://cdn.example' }); await expect( downloadAuthFor('https://cdn.example/py.tar.gz') - ).resolves.toBe('token MTOK'); + ).resolves.toBe('Bearer MTOK'); }); - it('prefers mirror-token over token for GitHub download URLs', async () => { - setInputs({token: 'TKN', 'mirror-token': 'MTOK'}); + it('does not prefix or rewrite a mirror-token', async () => { + setInputs({ + 'mirror-token': 'Basic dXNlcjpwYXNz', + mirror: 'https://cdn.example' + }); await expect( - downloadAuthFor('https://github.com/o/r/releases/download/v/py.tar.gz') + downloadAuthFor('https://cdn.example/py.tar.gz') + ).resolves.toBe('Basic dXNlcjpwYXNz'); + }); + + it('withholds mirror-token from an incidental GitHub host and uses token there', async () => { + setInputs({ + token: 'TKN', + 'mirror-token': 'MTOK', + mirror: 'https://cdn.example' + }); + // A manifest hosted on the private mirror may still point release assets at + // GitHub; the private credential must not follow them there. + await expect( + downloadAuthFor('https://objects.githubusercontent.com/x/python.tar.gz') + ).resolves.toBe('token TKN'); + }); + + it('withholds mirror-token from a GitHub host when no token is set', async () => { + setInputs({'mirror-token': 'MTOK', mirror: 'https://cdn.example'}); + await expect( + downloadAuthFor('https://objects.githubusercontent.com/x/python.tar.gz') + ).resolves.toBeUndefined(); + }); + + it('withholds mirror-token from a third host that is neither the mirror nor GitHub', async () => { + setInputs({ + token: 'TKN', + 'mirror-token': 'MTOK', + mirror: 'https://cdn.example' + }); + await expect( + downloadAuthFor('https://other.example/py.tar.gz') + ).resolves.toBeUndefined(); + }); + + it('uses mirror-token for a GitHub mirror host when it is the nominated host', async () => { + setInputs({ + token: 'TKN', + 'mirror-token': 'token MTOK', + mirror: 'https://raw.githubusercontent.com/foo/bar/main' + }); + await expect( + downloadAuthFor('https://raw.githubusercontent.com/foo/bar/py.tar.gz') ).resolves.toBe('token MTOK'); }); diff --git a/action.yml b/action.yml index 314ba2198..645de4f51 100644 --- a/action.yml +++ b/action.yml @@ -16,13 +16,13 @@ inputs: description: "Set this option if you want the action to check for the latest available version that satisfies the version spec." default: false token: - description: "The token used to authenticate when fetching Python distributions from https://github.com/actions/python-versions. When running this action on github.com, the default value is sufficient. When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting. When 'mirror-token' is set, it takes precedence over this input." + description: "The token used to authenticate when fetching Python distributions from https://github.com/actions/python-versions. When running this action on github.com, the default value is sufficient. When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting. This token is only sent to GitHub-owned hosts, never to a custom 'mirror'." default: ${{ github.server_url == 'https://github.com' && github.token || '' }} mirror: - description: "Base URL for downloading Python distributions. Defaults to https://raw.githubusercontent.com/actions/python-versions/main. See docs/advanced-usage.md for details." + description: "Base URL for downloading Python distributions (only applies to CPython; PyPy and GraalPy are unaffected). Defaults to https://raw.githubusercontent.com/actions/python-versions/main. See docs/advanced-usage.md for details." default: "https://raw.githubusercontent.com/actions/python-versions/main" mirror-token: - description: "Token used to authenticate requests to 'mirror'. Takes precedence over 'token'." + description: "Token used to authenticate requests to the host named in 'mirror'. Sent verbatim as the Authorization header, so include a scheme if your mirror needs one (e.g. 'Bearer ')." required: false cache-dependency-path: description: "Used to specify the path to dependency files. Supports wildcards or a list of file names for caching multiple dependencies." diff --git a/dist/setup/index.js b/dist/setup/index.js index cd2504d0d..276546e19 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -98705,29 +98705,69 @@ function getToken() { function getMirrorToken() { return getInput('mirror-token'); } +// Memoized per raw input value so the mirror is validated once per run rather +// than on every call. `getManifestUrl()` is also used to build the "version not +// found" message in find-python.ts, where re-validating would replace the real +// cause with an invalid-mirror error. +const mirrorCache = new Map(); function getMirror() { - const raw = (getInput('mirror') || DEFAULT_MIRROR) - .trim() - .replace(/\/+$/, ''); + const input = getInput('mirror') || DEFAULT_MIRROR; + let resolved = mirrorCache.get(input); + if (!resolved) { + const url = input.trim().replace(/\/+$/, ''); + try { + new URL(url); + resolved = { url }; + } + catch { + resolved = { error: new Error(`Invalid 'mirror' URL: "${url}"`) }; + } + mirrorCache.set(input, resolved); + } + if ('error' in resolved) + throw resolved.error; + return resolved.url; +} +function getManifestUrl() { + return `${getMirror()}/versions-manifest.json`; +} +function getMirrorHost() { try { - new URL(raw); + return new URL(getMirror()).host; } catch { - throw new Error(`Invalid 'mirror' URL: "${raw}"`); + return undefined; } - return raw; } -function getManifestUrl() { - return `${getMirror()}/versions-manifest.json`; +function isGitHubHost(host) { + return (host === 'github.com' || + host.endsWith('.github.com') || + host.endsWith('.githubusercontent.com')); } +// Warned at most once per distinct mirror; resolveRepoCoords() is called from +// several paths within a single run. +const warnedMirrors = new Set(); function resolveRepoCoords() { - const m = REPO_COORDS_RE.exec(getMirror()); - return m ? { owner: m[1], repo: m[2], branch: m[3] } : null; + const mirror = getMirror(); + const m = REPO_COORDS_RE.exec(mirror); + if (m) + return { owner: m[1], repo: m[2], branch: m[3] }; + // A raw.githubusercontent.com URL that doesn't parse is usually a branch + // name containing a slash, which is indistinguishable from a deeper path. + // Fetching still works, just anonymously and without the API rate limit. + if (!warnedMirrors.has(mirror) && + getMirrorHost() === 'raw.githubusercontent.com') { + warnedMirrors.add(mirror); + warning(`Could not parse owner/repo/branch out of mirror "${mirror}", so the manifest will be fetched by direct URL instead of the GitHub API. ` + + `Branch names containing '/' are not supported; use a branch without a slash to get the authenticated API rate limit.`); + } + return null; } +// Mirror host with `mirror-token` set gets the token verbatim, so internal +// mirrors can choose their own scheme (Bearer, Basic, ...). GitHub hosts get +// `token ${token}`. Anything else is anonymous — neither credential is sent to +// a host the user didn't nominate. function authForUrl(url) { - const mirrorToken = getMirrorToken(); - if (mirrorToken) - return `token ${mirrorToken}`; let host; try { host = new URL(url).host; @@ -98735,11 +98775,11 @@ function authForUrl(url) { catch { return undefined; } + const mirrorToken = getMirrorToken(); + if (mirrorToken && host === getMirrorHost()) + return mirrorToken; const token = getToken(); - if (token && - (host === 'github.com' || - host.endsWith('.github.com') || - host.endsWith('.githubusercontent.com'))) + if (token && isGitHubHost(host)) return `token ${token}`; return undefined; } @@ -98868,18 +98908,26 @@ async function fetchValidManifest(source, fetcher) { throw new Error(`Failed to fetch a valid manifest from ${source} after ${attempts} attempt(s): ${lastError?.message}`); } async function getManifest() { - try { - return await fetchValidManifest('the GitHub API', install_python_getManifestFromRepo); - } - catch (err) { - core_debug('Fetching the manifest via the API failed.'); - if (err instanceof Error) { - core_debug(err.message); + // Only GitHub repo mirrors can be fetched via the API. Checking up front + // avoids burning MANIFEST_FETCH_MAX_ATTEMPTS with backoff on a throw that + // could never succeed. + if (resolveRepoCoords()) { + try { + return await fetchValidManifest('the GitHub API', install_python_getManifestFromRepo); } - else { - core_debug('An unexpected error occurred while fetching the manifest.'); + catch (err) { + core_debug('Fetching the manifest via the API failed.'); + if (err instanceof Error) { + core_debug(err.message); + } + else { + core_debug('An unexpected error occurred while fetching the manifest.'); + } } } + else { + core_debug(`Mirror "${getMirror()}" is not a GitHub repo URL; fetching the manifest by URL.`); + } try { return await fetchValidManifest('the raw URL', getManifestFromURL); } @@ -98895,21 +98943,25 @@ function install_python_getManifestFromRepo() { throw new Error(`Mirror "${getMirror()}" is not a GitHub repo URL; falling back to raw URL fetch.`); } core_debug(`Getting manifest from ${coords.owner}/${coords.repo}@${coords.branch}`); - // api.github.com is a GitHub-owned URL. Prefer MIRROR_TOKEN (the user provided token), fall back to TOKEN. + // This only runs for GitHub repo mirrors, where `mirror-token` is the user's + // explicit intent for that repo. The target is always api.github.com, which + // requires the `token ` prefix, so the host rule in authForUrl() doesn't + // apply here. const token = getToken(); const mirrorToken = getMirrorToken(); - const auth = !mirrorToken - ? !token - ? undefined - : `token ${token}` - : `token ${mirrorToken}`; + const auth = mirrorToken + ? `token ${mirrorToken}` + : token + ? `token ${token}` + : undefined; return getManifestFromRepo(coords.owner, coords.repo, auth, coords.branch); } async function getManifestFromURL() { core_debug('Falling back to fetching the manifest using raw URL.'); const manifestUrl = getManifestUrl(); const http = new lib_HttpClient('tool-cache'); - const response = await http.getJson(manifestUrl); + const auth = authForUrl(manifestUrl); + const response = await http.getJson(manifestUrl, auth ? { authorization: auth } : undefined); if (!response.result) { throw new Error(`Unable to get manifest from ${manifestUrl}`); } @@ -103495,6 +103547,16 @@ function isPyPyVersion(versionSpec) { function isGraalPyVersion(versionSpec) { return versionSpec.startsWith('graalpy'); } +// `mirror` only redirects CPython distributions. PyPy and GraalPy resolve from +// downloads.python.org and the GitHub releases API respectively, so warn rather +// than let the input look like it applied. +function warnIfMirrorUnsupported(versionSpec) { + if (!getInput('mirror')) { + return; + } + const implementation = isPyPyVersion(versionSpec) ? 'PyPy' : 'GraalPy'; + warning(`The 'mirror' input only applies to CPython distributions and is ignored for ${implementation} ('${versionSpec}'), which is downloaded from its own upstream source.`); +} async function cacheDependencies(cache, pythonVersion) { const cacheDependencyPath = getInput('cache-dependency-path') || undefined; const cacheDistributor = getCacheDistributor(cache, pythonVersion, cacheDependencyPath); @@ -103556,11 +103618,13 @@ async function run() { startGroup('Installed versions'); for (const version of versions) { if (isPyPyVersion(version)) { + warnIfMirrorUnsupported(version); const installed = await findPyPyVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases); pythonVersion = `${installed.resolvedPyPyVersion}-${installed.resolvedPythonVersion}`; info(`Successfully set up PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})`); } else if (isGraalPyVersion(version)) { + warnIfMirrorUnsupported(version); const installed = await findGraalPyVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases); pythonVersion = `${installed}`; info(`Successfully set up GraalPy ${installed}`); diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index e75c22464..a6dbce6b3 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -533,12 +533,12 @@ The manifest is resolved as follows: - If `mirror` matches `https://raw.githubusercontent.com/{owner}/{repo}/{branch}`, the manifest is fetched via the GitHub REST API (giving you the 5000/hr authenticated rate limit when a token is present). - Otherwise, the action fetches `{mirror}/versions-manifest.json` via a direct HTTP GET. -Authentication: +Authentication is decided by the host of each request, so neither credential reaches a server you did not nominate: -- `token` is forwarded **only** to `github.com` and hosts under `*.github.com` or `*.githubusercontent.com`. It is never sent to a custom mirror. -- `mirror-token` takes precedence over `token`: if `mirror-token` is set it is used for every authenticated request (manifest fetch and tarball downloads). -- If `mirror-token` is empty, `token` is used when the target URL is GitHub-owned. -- If neither applies, requests are anonymous. +- Requests to the host named in `mirror` use `mirror-token`, sent **verbatim** as the `Authorization` header. Include a scheme if your mirror expects one — `Bearer `, `Basic `, or `token ` for a GitHub host. This covers both the manifest fetch and the tarball downloads. +- Requests to `github.com`, `*.github.com`, or `*.githubusercontent.com` use `token`, sent as `token `. A manifest that points its `download_url` at a GitHub host therefore keeps working without `mirror-token` being leaked to it. +- Any other host is requested anonymously. +- One exception: when `mirror` is a GitHub repo URL, the manifest is fetched from `api.github.com`, and `mirror-token` is preferred there (with the `token ` prefix the API requires) because naming a repo mirror is an explicit instruction to read that repo. Point at a personal fork of `actions/python-versions` (uses the default `token`, fetched via the GitHub API): @@ -559,6 +559,11 @@ Point at an internal mirror with its own credential: mirror-token: ${{ secrets.PYTHON_MIRROR_TOKEN }} ``` +Caveats: + +- `mirror` and `mirror-token` apply to **CPython only**. PyPy resolves from `downloads.python.org` and GraalPy from the GitHub releases API; both ignore these inputs, and the action warns if you set `mirror` alongside a `pypy-*` or `graalpy-*` version. +- Branch names containing `/` cannot be used with a `raw.githubusercontent.com` mirror, because `.../{owner}/{repo}/feature/riscv` is indistinguishable from a repo path. Such a mirror still works, but falls back to an anonymous direct GET with the 60/hr unauthenticated rate limit; the action warns when this happens. Use a branch without a slash to get the API path. + ### PyPy `setup-python` is able to configure **PyPy** from two sources: diff --git a/src/install-python.ts b/src/install-python.ts index 8870bc963..61c42ffe3 100644 --- a/src/install-python.ts +++ b/src/install-python.ts @@ -26,48 +26,99 @@ function getMirrorToken(): string { return core.getInput('mirror-token'); } +// Memoized per raw input value so the mirror is validated once per run rather +// than on every call. `getManifestUrl()` is also used to build the "version not +// found" message in find-python.ts, where re-validating would replace the real +// cause with an invalid-mirror error. +const mirrorCache = new Map(); + function getMirror(): string { - const raw = (core.getInput('mirror') || DEFAULT_MIRROR) - .trim() - .replace(/\/+$/, ''); - try { - new URL(raw); - } catch { - throw new Error(`Invalid 'mirror' URL: "${raw}"`); + const input = core.getInput('mirror') || DEFAULT_MIRROR; + let resolved = mirrorCache.get(input); + + if (!resolved) { + const url = input.trim().replace(/\/+$/, ''); + try { + new URL(url); + resolved = {url}; + } catch { + resolved = {error: new Error(`Invalid 'mirror' URL: "${url}"`)}; + } + mirrorCache.set(input, resolved); } - return raw; + + if ('error' in resolved) throw resolved.error; + return resolved.url; } export function getManifestUrl(): string { return `${getMirror()}/versions-manifest.json`; } -function resolveRepoCoords(): { +function getMirrorHost(): string | undefined { + try { + return new URL(getMirror()).host; + } catch { + return undefined; + } +} + +function isGitHubHost(host: string): boolean { + return ( + host === 'github.com' || + host.endsWith('.github.com') || + host.endsWith('.githubusercontent.com') + ); +} + +// Warned at most once per distinct mirror; resolveRepoCoords() is called from +// several paths within a single run. +const warnedMirrors = new Set(); + +export function resolveRepoCoords(): { owner: string; repo: string; branch: string; } | null { - const m = REPO_COORDS_RE.exec(getMirror()); - return m ? {owner: m[1], repo: m[2], branch: m[3]} : null; + const mirror = getMirror(); + const m = REPO_COORDS_RE.exec(mirror); + if (m) return {owner: m[1], repo: m[2], branch: m[3]}; + + // A raw.githubusercontent.com URL that doesn't parse is usually a branch + // name containing a slash, which is indistinguishable from a deeper path. + // Fetching still works, just anonymously and without the API rate limit. + if ( + !warnedMirrors.has(mirror) && + getMirrorHost() === 'raw.githubusercontent.com' + ) { + warnedMirrors.add(mirror); + core.warning( + `Could not parse owner/repo/branch out of mirror "${mirror}", so the manifest will be fetched by direct URL instead of the GitHub API. ` + + `Branch names containing '/' are not supported; use a branch without a slash to get the authenticated API rate limit.` + ); + } + + return null; } +// Mirror host with `mirror-token` set gets the token verbatim, so internal +// mirrors can choose their own scheme (Bearer, Basic, ...). GitHub hosts get +// `token ${token}`. Anything else is anonymous — neither credential is sent to +// a host the user didn't nominate. function authForUrl(url: string): string | undefined { - const mirrorToken = getMirrorToken(); - if (mirrorToken) return `token ${mirrorToken}`; let host: string; try { host = new URL(url).host; } catch { return undefined; } + + const mirrorToken = getMirrorToken(); + if (mirrorToken && host === getMirrorHost()) return mirrorToken; + const token = getToken(); - if ( - token && - (host === 'github.com' || - host.endsWith('.github.com') || - host.endsWith('.githubusercontent.com')) - ) - return `token ${token}`; + if (token && isGitHubHost(host)) return `token ${token}`; + return undefined; } @@ -260,15 +311,24 @@ async function fetchValidManifest( } export async function getManifest(): Promise { - try { - return await fetchValidManifest('the GitHub API', getManifestFromRepo); - } catch (err) { - core.debug('Fetching the manifest via the API failed.'); - if (err instanceof Error) { - core.debug(err.message); - } else { - core.debug('An unexpected error occurred while fetching the manifest.'); + // Only GitHub repo mirrors can be fetched via the API. Checking up front + // avoids burning MANIFEST_FETCH_MAX_ATTEMPTS with backoff on a throw that + // could never succeed. + if (resolveRepoCoords()) { + try { + return await fetchValidManifest('the GitHub API', getManifestFromRepo); + } catch (err) { + core.debug('Fetching the manifest via the API failed.'); + if (err instanceof Error) { + core.debug(err.message); + } else { + core.debug('An unexpected error occurred while fetching the manifest.'); + } } + } else { + core.debug( + `Mirror "${getMirror()}" is not a GitHub repo URL; fetching the manifest by URL.` + ); } try { @@ -293,14 +353,17 @@ export function getManifestFromRepo(): Promise { core.debug( `Getting manifest from ${coords.owner}/${coords.repo}@${coords.branch}` ); - // api.github.com is a GitHub-owned URL. Prefer MIRROR_TOKEN (the user provided token), fall back to TOKEN. + // This only runs for GitHub repo mirrors, where `mirror-token` is the user's + // explicit intent for that repo. The target is always api.github.com, which + // requires the `token ` prefix, so the host rule in authForUrl() doesn't + // apply here. const token = getToken(); const mirrorToken = getMirrorToken(); - const auth = !mirrorToken - ? !token - ? undefined - : `token ${token}` - : `token ${mirrorToken}`; + const auth = mirrorToken + ? `token ${mirrorToken}` + : token + ? `token ${token}` + : undefined; return tc.getManifestFromRepo(coords.owner, coords.repo, auth, coords.branch); } @@ -309,7 +372,11 @@ export async function getManifestFromURL(): Promise { const manifestUrl = getManifestUrl(); const http: httpm.HttpClient = new httpm.HttpClient('tool-cache'); - const response = await http.getJson(manifestUrl); + const auth = authForUrl(manifestUrl); + const response = await http.getJson( + manifestUrl, + auth ? {authorization: auth} : undefined + ); if (!response.result) { throw new Error(`Unable to get manifest from ${manifestUrl}`); } diff --git a/src/setup-python.ts b/src/setup-python.ts index 2b062caac..41742fccc 100644 --- a/src/setup-python.ts +++ b/src/setup-python.ts @@ -23,6 +23,19 @@ function isGraalPyVersion(versionSpec: string) { return versionSpec.startsWith('graalpy'); } +// `mirror` only redirects CPython distributions. PyPy and GraalPy resolve from +// downloads.python.org and the GitHub releases API respectively, so warn rather +// than let the input look like it applied. +function warnIfMirrorUnsupported(versionSpec: string) { + if (!core.getInput('mirror')) { + return; + } + const implementation = isPyPyVersion(versionSpec) ? 'PyPy' : 'GraalPy'; + core.warning( + `The 'mirror' input only applies to CPython distributions and is ignored for ${implementation} ('${versionSpec}'), which is downloaded from its own upstream source.` + ); +} + async function cacheDependencies(cache: string, pythonVersion: string) { const cacheDependencyPath = core.getInput('cache-dependency-path') || undefined; @@ -102,6 +115,7 @@ async function run() { core.startGroup('Installed versions'); for (const version of versions) { if (isPyPyVersion(version)) { + warnIfMirrorUnsupported(version); const installed = await finderPyPy.findPyPyVersion( version, arch, @@ -114,6 +128,7 @@ async function run() { `Successfully set up PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})` ); } else if (isGraalPyVersion(version)) { + warnIfMirrorUnsupported(version); const installed = await finderGraalPy.findGraalPyVersion( version, arch,