diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 5dfd15d1..dfe9aa9f 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -58,6 +58,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.test.ts b/__tests__/install-python.test.ts index 51f9fa77..99aa8dcf 100644 --- a/__tests__/install-python.test.ts +++ b/__tests__/install-python.test.ts @@ -1,16 +1,29 @@ import { getManifest, getManifestFromRepo, - getManifestFromURL + getManifestFromURL, + installCpythonFromRelease } from '../src/install-python'; import * as httpm from '@actions/http-client'; import * as tc from '@actions/tool-cache'; jest.mock('@actions/http-client'); -jest.mock('@actions/tool-cache'); jest.mock('@actions/tool-cache', () => ({ - getManifestFromRepo: jest.fn() + getManifestFromRepo: jest.fn(), + downloadTool: jest.fn(), + extractTar: jest.fn(), + extractZip: jest.fn(), + HTTPError: class HTTPError extends Error {} })); +jest.mock('@actions/exec', () => ({ + exec: jest.fn().mockResolvedValue(0) +})); +jest.mock('../src/utils', () => ({ + ...jest.requireActual('../src/utils'), + IS_WINDOWS: false, + IS_LINUX: false +})); + const mockManifest = [ { version: '1.0.0', @@ -26,11 +39,27 @@ const mockManifest = [ } ]; -describe('getManifest', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); +function setInputs(values: Record) { + for (const key of ['TOKEN', 'MIRROR', 'MIRROR-TOKEN']) { + delete process.env[`INPUT_${key}`]; + } + for (const [k, v] of Object.entries(values)) { + if (v !== undefined) { + process.env[`INPUT_${k.toUpperCase()}`] = v; + } + } +} +beforeEach(() => { + jest.resetAllMocks(); + setInputs({}); +}); + +afterAll(() => { + setInputs({}); +}); + +describe('getManifest', () => { it('should return manifest from repo', async () => { (tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest); const manifest = await getManifest(); @@ -50,10 +79,82 @@ describe('getManifest', () => { }); describe('getManifestFromRepo', () => { - it('should return manifest from repo', async () => { + it('default mirror calls getManifestFromRepo with actions/python-versions@main and token', async () => { + setInputs({token: 'TKN'}); (tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest); - const manifest = await getManifestFromRepo(); - expect(manifest).toEqual(mockManifest); + await getManifestFromRepo(); + expect(tc.getManifestFromRepo).toHaveBeenCalledWith( + 'actions', + 'python-versions', + 'token TKN', + 'main' + ); + }); + + it('custom raw mirror extracts owner/repo/branch and passes token', 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('custom non-GitHub mirror throws (caller falls through to URL fetch)', () => { + setInputs({mirror: 'https://mirror.example/py'}); + expect(() => getManifestFromRepo()).toThrow(/not a GitHub repo URL/); + }); + + it('mirror-token wins over token for the api.github.com call (getManifestFromRepo)', 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('token is used when mirror-token is empty (getManifestFromRepo)', 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('trailing slashes in mirror URL are stripped', 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' + ); }); }); @@ -74,4 +175,116 @@ describe('getManifestFromURL', () => { 'Unable to get manifest from' ); }); + + it('fetches from {mirror}/versions-manifest.json (no auth header attached)', async () => { + setInputs({token: 'TKN', mirror: 'https://mirror.example/py'}); + (httpm.HttpClient.prototype.getJson as jest.Mock).mockResolvedValue({ + result: mockManifest + }); + await getManifestFromURL(); + expect(httpm.HttpClient.prototype.getJson).toHaveBeenCalledWith( + 'https://mirror.example/py/versions-manifest.json' + ); + }); +}); + +describe('mirror URL validation', () => { + it('throws on invalid URL when used', () => { + setInputs({mirror: 'not a url'}); + expect(() => getManifestFromRepo()).toThrow(/Invalid 'mirror' URL/); + }); +}); + +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; + + function stubInstallExtract() { + (tc.downloadTool as jest.Mock).mockResolvedValue('/tmp/py.tgz'); + (tc.extractTar as jest.Mock).mockResolvedValue('/tmp/extracted'); + } + + it('forwards token to github.com download URLs', async () => { + setInputs({token: 'TKN'}); + stubInstallExtract(); + await installCpythonFromRelease( + makeRelease( + 'https://github.com/actions/python-versions/releases/download/3.12.0-x/python-3.12.0-linux-x64.tar.gz' + ) + ); + expect(tc.downloadTool).toHaveBeenCalledWith( + expect.any(String), + undefined, + 'token TKN' + ); + }); + + it('forwards token to api.github.com URLs', async () => { + setInputs({token: 'TKN'}); + stubInstallExtract(); + await installCpythonFromRelease( + makeRelease('https://api.github.com/repos/x/y/tarball/main') + ); + expect(tc.downloadTool).toHaveBeenCalledWith( + expect.any(String), + undefined, + 'token TKN' + ); + }); + + it('forwards token to objects.githubusercontent.com download URLs', async () => { + setInputs({token: 'TKN'}); + stubInstallExtract(); + await installCpythonFromRelease( + makeRelease('https://objects.githubusercontent.com/x/python.tar.gz') + ); + expect(tc.downloadTool).toHaveBeenCalledWith( + expect.any(String), + undefined, + 'token TKN' + ); + }); + + it('does NOT forward token to non-GitHub download URLs', async () => { + setInputs({token: 'TKN'}); + stubInstallExtract(); + await installCpythonFromRelease( + makeRelease('https://cdn.example/py.tar.gz') + ); + expect(tc.downloadTool).toHaveBeenCalledWith( + expect.any(String), + undefined, + undefined + ); + }); + + it('forwards mirror-token to non-GitHub download URLs', async () => { + setInputs({ + token: 'TKN', + 'mirror-token': 'MTOK', + mirror: 'https://cdn.example' + }); + stubInstallExtract(); + await installCpythonFromRelease( + makeRelease('https://cdn.example/py.tar.gz') + ); + expect(tc.downloadTool).toHaveBeenCalledWith( + expect.any(String), + undefined, + 'token MTOK' + ); + }); }); diff --git a/action.yml b/action.yml index 7a9a7b63..ef789b2b 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 41363d8b..ef8a551c 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -83007,7 +83007,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); @@ -83617,7 +83617,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; @@ -83629,12 +83629,56 @@ const tc = __importStar(__nccwpck_require__(33472)); const exec = __importStar(__nccwpck_require__(95236)); 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_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 core.getInput('token'); +} +function getMirrorToken() { + return core.getInput('mirror-token'); +} +function getMirror() { + const raw = (core.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; +} async function findReleaseFromManifest(semanticVersionSpec, architecture, manifest) { if (!manifest) { manifest = await getManifest(); @@ -83675,15 +83719,28 @@ 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 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 tc.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 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; } @@ -83720,7 +83777,7 @@ async function installCpythonFromRelease(release) { let pythonPath = ''; try { const fileName = (0, utils_1.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 (utils_1.IS_WINDOWS) { diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 3f099623..88b090fd 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -525,6 +525,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 99c6a7f2..fd27c5a2 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 bef0161c..90d66001 100644 --- a/src/install-python.ts +++ b/src/install-python.ts @@ -7,12 +7,67 @@ import {ExecOptions} from '@actions/exec/lib/interfaces'; import {IS_WINDOWS, IS_LINUX, getDownloadFileName} from './utils'; 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; +} export async function findReleaseFromManifest( semanticVersionSpec: string, @@ -73,24 +128,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; } @@ -130,7 +195,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) {