feat: Add mirror and mirror-token inputs for custom Python distribution sources

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.
This commit is contained in:
Ludovic Henry 2026-04-06 00:59:40 +02:00
parent 8549b9f8f5
commit f30f2fee26
No known key found for this signature in database
7 changed files with 559 additions and 35 deletions

View File

@ -61,6 +61,26 @@ jobs:
- name: Run simple code - name: Run simple code
run: python -c 'import math; print(math.factorial(5))' 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: setup-versions-from-file:
name: Setup ${{ matrix.python }} ${{ matrix.os }} version file name: Setup ${{ matrix.python }} ${{ matrix.os }} version file
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}

View File

@ -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<string, string> = {};
// 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<unknown>) => 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<string, string>) {
Object.assign(inputs, values);
}
beforeEach(() => {
jest.resetAllMocks();
for (const key of Object.keys(inputs)) {
delete inputs[key];
}
(core.getInput as jest.Mock<any>).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<any>).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<any>).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<any>).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<any>).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<any>).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<any>).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<any>).mockResolvedValue('/tmp/py.tgz');
(tc.extractTar as jest.Mock<any>).mockResolvedValue('/tmp/extracted');
await installCpythonFromRelease(makeRelease(downloadUrl));
const call = (tc.downloadTool as jest.Mock<any>).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();
});
});

View File

@ -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." description: "Set this option if you want the action to check for the latest available version that satisfies the version spec."
default: false default: false
token: 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 || '' }} 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: cache-dependency-path:
description: "Used to specify the path to dependency files. Supports wildcards or a list of file names for caching multiple dependencies." description: "Used to specify the path to dependency files. Supports wildcards or a list of file names for caching multiple dependencies."
update-environment: update-environment:

91
dist/setup/index.js vendored
View File

@ -98693,12 +98693,56 @@ function _unique(values) {
const TOKEN = getInput('token'); const DEFAULT_REPO_OWNER = 'actions';
const AUTH = !TOKEN ? undefined : `token ${TOKEN}`; const DEFAULT_REPO_NAME = 'python-versions';
const MANIFEST_REPO_OWNER = 'actions'; const DEFAULT_REPO_BRANCH = 'main';
const MANIFEST_REPO_NAME = 'python-versions'; const DEFAULT_MIRROR = `https://raw.githubusercontent.com/${DEFAULT_REPO_OWNER}/${DEFAULT_REPO_NAME}/${DEFAULT_REPO_BRANCH}`;
const MANIFEST_REPO_BRANCH = 'main'; // Matches https://raw.githubusercontent.com/{owner}/{repo}/{branch}
const MANIFEST_URL = `https://raw.githubusercontent.com/${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}/${MANIFEST_REPO_BRANCH}/versions-manifest.json`; 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() { function getLinuxOsRelease() {
try { try {
const content = external_fs_namespaceObject.readFileSync('/etc/os-release', 'utf8'); const content = external_fs_namespaceObject.readFileSync('/etc/os-release', 'utf8');
@ -98846,15 +98890,28 @@ async function getManifest() {
} }
} }
function install_python_getManifestFromRepo() { function install_python_getManifestFromRepo() {
core_debug(`Getting manifest from ${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}@${MANIFEST_REPO_BRANCH}`); const coords = resolveRepoCoords();
return getManifestFromRepo(MANIFEST_REPO_OWNER, MANIFEST_REPO_NAME, AUTH, MANIFEST_REPO_BRANCH); 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() { async function getManifestFromURL() {
core_debug('Falling back to fetching the manifest using raw URL.'); core_debug('Falling back to fetching the manifest using raw URL.');
const manifestUrl = getManifestUrl();
const http = new lib_HttpClient('tool-cache'); const http = new lib_HttpClient('tool-cache');
const response = await http.getJson(MANIFEST_URL); const response = await http.getJson(manifestUrl);
if (!response.result) { 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; return response.result;
} }
@ -98897,7 +98954,7 @@ async function installCpythonFromRelease(release) {
let pythonPath = ''; let pythonPath = '';
try { try {
const fileName = getDownloadFileName(downloadUrl); const fileName = getDownloadFileName(downloadUrl);
pythonPath = await downloadTool(downloadUrl, fileName, AUTH); pythonPath = await downloadTool(downloadUrl, fileName, authForUrl(downloadUrl));
info('Extract downloaded archive'); info('Extract downloaded archive');
let pythonExtractedFolder; let pythonExtractedFolder;
if (utils_IS_WINDOWS) { if (utils_IS_WINDOWS) {
@ -99015,7 +99072,7 @@ async function useCpythonVersion(version, architecture, updateEnvironment, check
if (freethreaded) { if (freethreaded) {
msg.push(`Free threaded versions are only available for Python 3.13.0 and later.`); 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)); throw new Error(msg.join(external_os_.EOL));
} }
const _binDir = binDir(installDir); const _binDir = binDir(installDir);
@ -99417,8 +99474,8 @@ function findPyPyInstallDirForWindows(pythonVersion) {
const install_graalpy_TOKEN = getInput('token'); const TOKEN = getInput('token');
const install_graalpy_AUTH = !install_graalpy_TOKEN ? undefined : `token ${install_graalpy_TOKEN}`; const AUTH = !TOKEN ? undefined : `token ${TOKEN}`;
async function installGraalPy(graalpyVersion, architecture, allowPreReleases, releases) { async function installGraalPy(graalpyVersion, architecture, allowPreReleases, releases) {
let downloadDir; let downloadDir;
releases = releases ?? (await getAvailableGraalPyVersions()); releases = releases ?? (await getAvailableGraalPyVersions());
@ -99441,7 +99498,7 @@ async function installGraalPy(graalpyVersion, architecture, allowPreReleases, re
const downloadUrl = `${foundAsset.browser_download_url}`; const downloadUrl = `${foundAsset.browser_download_url}`;
info(`Downloading GraalPy from "${downloadUrl}" ...`); info(`Downloading GraalPy from "${downloadUrl}" ...`);
try { try {
const graalpyPath = await downloadTool(downloadUrl, undefined, install_graalpy_AUTH); const graalpyPath = await downloadTool(downloadUrl, undefined, AUTH);
info('Extracting downloaded archive...'); info('Extracting downloaded archive...');
if (utils_IS_WINDOWS) { if (utils_IS_WINDOWS) {
downloadDir = await extractZip(graalpyPath); downloadDir = await extractZip(graalpyPath);
@ -99482,8 +99539,8 @@ async function installGraalPy(graalpyVersion, architecture, allowPreReleases, re
async function getAvailableGraalPyVersions() { async function getAvailableGraalPyVersions() {
const http = new lib_HttpClient('tool-cache'); const http = new lib_HttpClient('tool-cache');
const headers = {}; const headers = {};
if (install_graalpy_AUTH) { if (AUTH) {
headers.authorization = install_graalpy_AUTH; headers.authorization = AUTH;
} }
/* /*
Get releases first. Get releases first.

View File

@ -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. >**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 ### PyPy
`setup-python` is able to configure **PyPy** from two sources: `setup-python` is able to configure **PyPy** from two sources:

View File

@ -137,7 +137,7 @@ export async function useCpythonVersion(
); );
} }
msg.push( 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)); throw new Error(msg.join(os.EOL));
} }

View File

@ -9,12 +9,67 @@ import * as semver from 'semver';
import {IS_WINDOWS, IS_LINUX, getDownloadFileName} from './utils.js'; import {IS_WINDOWS, IS_LINUX, getDownloadFileName} from './utils.js';
import {IToolRelease} from '@actions/tool-cache'; import {IToolRelease} from '@actions/tool-cache';
const TOKEN = core.getInput('token'); const DEFAULT_REPO_OWNER = 'actions';
const AUTH = !TOKEN ? undefined : `token ${TOKEN}`; const DEFAULT_REPO_NAME = 'python-versions';
const MANIFEST_REPO_OWNER = 'actions'; const DEFAULT_REPO_BRANCH = 'main';
const MANIFEST_REPO_NAME = 'python-versions'; const DEFAULT_MIRROR = `https://raw.githubusercontent.com/${DEFAULT_REPO_OWNER}/${DEFAULT_REPO_NAME}/${DEFAULT_REPO_BRANCH}`;
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`; // 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 { interface LinuxOsRelease {
id: string; id: string;
@ -229,24 +284,34 @@ export async function getManifest(): Promise<tc.IToolRelease[]> {
} }
export function getManifestFromRepo(): Promise<tc.IToolRelease[]> { export function getManifestFromRepo(): Promise<tc.IToolRelease[]> {
const coords = resolveRepoCoords();
if (!coords) {
throw new Error(
`Mirror "${getMirror()}" is not a GitHub repo URL; falling back to raw URL fetch.`
);
}
core.debug( core.debug(
`Getting manifest from ${MANIFEST_REPO_OWNER}/${MANIFEST_REPO_NAME}@${MANIFEST_REPO_BRANCH}` `Getting manifest from ${coords.owner}/${coords.repo}@${coords.branch}`
);
return tc.getManifestFromRepo(
MANIFEST_REPO_OWNER,
MANIFEST_REPO_NAME,
AUTH,
MANIFEST_REPO_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<tc.IToolRelease[]> { export async function getManifestFromURL(): Promise<tc.IToolRelease[]> {
core.debug('Falling back to fetching the manifest using raw URL.'); core.debug('Falling back to fetching the manifest using raw URL.');
const manifestUrl = getManifestUrl();
const http: httpm.HttpClient = new httpm.HttpClient('tool-cache'); const http: httpm.HttpClient = new httpm.HttpClient('tool-cache');
const response = await http.getJson<tc.IToolRelease[]>(MANIFEST_URL); const response = await http.getJson<tc.IToolRelease[]>(manifestUrl);
if (!response.result) { 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; return response.result;
} }
@ -291,7 +356,11 @@ export async function installCpythonFromRelease(release: tc.IToolRelease) {
let pythonPath = ''; let pythonPath = '';
try { try {
const fileName = getDownloadFileName(downloadUrl); const fileName = getDownloadFileName(downloadUrl);
pythonPath = await tc.downloadTool(downloadUrl, fileName, AUTH); pythonPath = await tc.downloadTool(
downloadUrl,
fileName,
authForUrl(downloadUrl)
);
core.info('Extract downloaded archive'); core.info('Extract downloaded archive');
let pythonExtractedFolder; let pythonExtractedFolder;
if (IS_WINDOWS) { if (IS_WINDOWS) {