10
0
Fork 0
mirror of https://github.com/actions/setup-python.git synced 2026-04-06 18:06:53 +00:00

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 28f2168f4d
commit 8b57351c0f
7 changed files with 441 additions and 41 deletions

View file

@ -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<string, string | undefined>) {
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'
);
});
});