Skip to content

Commit f30f2fe

Browse files
committed
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.
1 parent 8549b9f commit f30f2fe

7 files changed

Lines changed: 559 additions & 35 deletions

File tree

.github/workflows/test-python.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,26 @@ jobs:
6161
- name: Run simple code
6262
run: python -c 'import math; print(math.factorial(5))'
6363

64+
setup-versions-via-mirror-input:
65+
name: 'Setup via explicit mirror input: ${{ matrix.os }}'
66+
runs-on: ${{ matrix.os }}
67+
strategy:
68+
fail-fast: false
69+
matrix:
70+
os: [ubuntu-latest, windows-latest, macos-latest]
71+
steps:
72+
- name: Checkout
73+
uses: actions/checkout@v6
74+
75+
- name: setup-python with explicit mirror
76+
uses: ./
77+
with:
78+
python-version: 3.12
79+
mirror: https://raw.githubusercontent.com/actions/python-versions/main
80+
81+
- name: Run simple code
82+
run: python -c 'import sys; print(sys.version)'
83+
6484
setup-versions-from-file:
6585
name: Setup ${{ matrix.python }} ${{ matrix.os }} version file
6686
runs-on: ${{ matrix.os }}
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
import {jest, describe, it, expect, beforeEach} from '@jest/globals';
2+
3+
// Inputs are read lazily by install-python.ts, so each test can set them
4+
// before invoking the function under test.
5+
const inputs: Record<string, string> = {};
6+
7+
// Mock @actions/http-client
8+
jest.unstable_mockModule('@actions/http-client', () => ({
9+
HttpClient: jest.fn().mockImplementation(() => ({
10+
getJson: jest.fn()
11+
})),
12+
HttpClientError: class HttpClientError extends Error {},
13+
HttpCodes: {
14+
OK: 200,
15+
NotFound: 404,
16+
InternalServerError: 500
17+
}
18+
}));
19+
20+
// Mock @actions/cache (needed transitively by utils.ts)
21+
jest.unstable_mockModule('@actions/cache', () => ({
22+
saveCache: jest.fn(),
23+
restoreCache: jest.fn(),
24+
isFeatureAvailable: jest.fn()
25+
}));
26+
27+
// Mock @actions/tool-cache
28+
jest.unstable_mockModule('@actions/tool-cache', () => ({
29+
getManifestFromRepo: jest.fn(),
30+
downloadTool: jest.fn(),
31+
extractTar: jest.fn(),
32+
extractZip: jest.fn(),
33+
HTTPError: class HTTPError extends Error {}
34+
}));
35+
36+
// Mock @actions/core (needed by install-python.ts)
37+
jest.unstable_mockModule('@actions/core', () => ({
38+
info: jest.fn(),
39+
warning: jest.fn(),
40+
debug: jest.fn(),
41+
error: jest.fn(),
42+
notice: jest.fn(),
43+
setFailed: jest.fn(),
44+
setOutput: jest.fn(),
45+
getInput: jest.fn(),
46+
getBooleanInput: jest.fn(),
47+
getMultilineInput: jest.fn(),
48+
addPath: jest.fn(),
49+
exportVariable: jest.fn(),
50+
saveState: jest.fn(),
51+
getState: jest.fn(),
52+
setSecret: jest.fn(),
53+
isDebug: jest.fn(() => false),
54+
startGroup: jest.fn(),
55+
endGroup: jest.fn(),
56+
group: jest.fn((_name: string, fn: () => Promise<unknown>) => fn()),
57+
toPlatformPath: jest.fn((p: string) => p),
58+
toWin32Path: jest.fn((p: string) => p),
59+
toPosixPath: jest.fn((p: string) => p)
60+
}));
61+
62+
// Mock @actions/exec (needed by install-python.ts)
63+
jest.unstable_mockModule('@actions/exec', () => ({
64+
exec: jest.fn(),
65+
getExecOutput: jest.fn()
66+
}));
67+
68+
// Import real utils BEFORE mock registration to get real function references
69+
const realUtils = await import('../src/utils.js');
70+
71+
// Pin the platform so the download/extract assertions below behave the same
72+
// on every runner OS.
73+
jest.unstable_mockModule('../src/utils.js', () => ({
74+
...realUtils,
75+
IS_WINDOWS: false,
76+
IS_LINUX: false
77+
}));
78+
79+
// Dynamic imports after mocking
80+
const core = await import('@actions/core');
81+
const httpm = await import('@actions/http-client');
82+
const tc = await import('@actions/tool-cache');
83+
const {
84+
getManifestUrl,
85+
getManifestFromRepo,
86+
getManifestFromURL,
87+
installCpythonFromRelease
88+
} = await import('../src/install-python.js');
89+
90+
const DEFAULT_MIRROR =
91+
'https://raw.githubusercontent.com/actions/python-versions/main';
92+
93+
const mockManifest = [
94+
{
95+
version: '1.0.0',
96+
stable: true,
97+
files: [
98+
{
99+
filename: 'tool-v1.0.0-linux-x64.tar.gz',
100+
platform: 'linux',
101+
arch: 'x64',
102+
download_url: 'https://example.com/tool-v1.0.0-linux-x64.tar.gz'
103+
}
104+
]
105+
}
106+
];
107+
108+
function setInputs(values: Record<string, string>) {
109+
Object.assign(inputs, values);
110+
}
111+
112+
beforeEach(() => {
113+
jest.resetAllMocks();
114+
for (const key of Object.keys(inputs)) {
115+
delete inputs[key];
116+
}
117+
(core.getInput as jest.Mock<any>).mockImplementation(
118+
(name: string) => inputs[name] ?? ''
119+
);
120+
});
121+
122+
describe('getManifestUrl', () => {
123+
it('defaults to the actions/python-versions manifest', () => {
124+
expect(getManifestUrl()).toBe(`${DEFAULT_MIRROR}/versions-manifest.json`);
125+
});
126+
127+
it('appends versions-manifest.json to a custom mirror', () => {
128+
setInputs({mirror: 'https://mirror.example/py'});
129+
expect(getManifestUrl()).toBe(
130+
'https://mirror.example/py/versions-manifest.json'
131+
);
132+
});
133+
134+
it('strips trailing slashes from the mirror', () => {
135+
setInputs({mirror: 'https://mirror.example/py///'});
136+
expect(getManifestUrl()).toBe(
137+
'https://mirror.example/py/versions-manifest.json'
138+
);
139+
});
140+
141+
it('throws on a mirror that is not a valid URL', () => {
142+
setInputs({mirror: 'not a url'});
143+
expect(() => getManifestUrl()).toThrow(/Invalid 'mirror' URL/);
144+
});
145+
});
146+
147+
describe('getManifestFromRepo mirror resolution', () => {
148+
it('resolves the default mirror to actions/python-versions@main with token', async () => {
149+
setInputs({token: 'TKN'});
150+
(tc.getManifestFromRepo as jest.Mock<any>).mockResolvedValue(mockManifest);
151+
152+
await getManifestFromRepo();
153+
154+
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
155+
'actions',
156+
'python-versions',
157+
'token TKN',
158+
'main'
159+
);
160+
});
161+
162+
it('extracts owner/repo/branch from a custom raw.githubusercontent.com mirror', async () => {
163+
setInputs({
164+
token: 'TKN',
165+
mirror: 'https://raw.githubusercontent.com/foo/bar/dev'
166+
});
167+
(tc.getManifestFromRepo as jest.Mock<any>).mockResolvedValue(mockManifest);
168+
169+
await getManifestFromRepo();
170+
171+
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
172+
'foo',
173+
'bar',
174+
'token TKN',
175+
'dev'
176+
);
177+
});
178+
179+
it('strips a trailing slash before extracting the branch', async () => {
180+
setInputs({
181+
token: 'TKN',
182+
mirror: 'https://raw.githubusercontent.com/foo/bar/main/'
183+
});
184+
(tc.getManifestFromRepo as jest.Mock<any>).mockResolvedValue(mockManifest);
185+
186+
await getManifestFromRepo();
187+
188+
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
189+
'foo',
190+
'bar',
191+
'token TKN',
192+
'main'
193+
);
194+
});
195+
196+
it('throws for a non-GitHub mirror so the caller falls back to the raw URL', () => {
197+
setInputs({mirror: 'https://mirror.example/py'});
198+
expect(() => getManifestFromRepo()).toThrow(/not a GitHub repo URL/);
199+
expect(tc.getManifestFromRepo).not.toHaveBeenCalled();
200+
});
201+
202+
it('prefers mirror-token over token for the GitHub API call', async () => {
203+
setInputs({
204+
token: 'TKN',
205+
'mirror-token': 'MTOK',
206+
mirror: 'https://raw.githubusercontent.com/foo/bar/main'
207+
});
208+
(tc.getManifestFromRepo as jest.Mock<any>).mockResolvedValue(mockManifest);
209+
210+
await getManifestFromRepo();
211+
212+
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
213+
'foo',
214+
'bar',
215+
'token MTOK',
216+
'main'
217+
);
218+
});
219+
220+
it('sends no auth when neither token nor mirror-token is set', async () => {
221+
(tc.getManifestFromRepo as jest.Mock<any>).mockResolvedValue(mockManifest);
222+
223+
await getManifestFromRepo();
224+
225+
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
226+
'actions',
227+
'python-versions',
228+
undefined,
229+
'main'
230+
);
231+
});
232+
});
233+
234+
describe('getManifestFromURL mirror resolution', () => {
235+
it('fetches {mirror}/versions-manifest.json without attaching auth', async () => {
236+
setInputs({token: 'TKN', mirror: 'https://mirror.example/py'});
237+
const getJson = jest.fn(async () => ({result: mockManifest}));
238+
(httpm.HttpClient as jest.Mock<any>).mockImplementation(() => ({getJson}));
239+
240+
await getManifestFromURL();
241+
242+
expect(getJson).toHaveBeenCalledWith(
243+
'https://mirror.example/py/versions-manifest.json'
244+
);
245+
});
246+
});
247+
248+
describe('installCpythonFromRelease auth gating', () => {
249+
const makeRelease = (downloadUrl: string) =>
250+
({
251+
version: '3.12.0',
252+
stable: true,
253+
release_url: '',
254+
files: [
255+
{
256+
filename: 'python-3.12.0-linux-x64.tar.gz',
257+
platform: 'linux',
258+
platform_version: '',
259+
arch: 'x64',
260+
download_url: downloadUrl
261+
}
262+
]
263+
}) as any;
264+
265+
// Returns the auth argument tc.downloadTool was called with.
266+
async function downloadAuthFor(downloadUrl: string) {
267+
(tc.downloadTool as jest.Mock<any>).mockResolvedValue('/tmp/py.tgz');
268+
(tc.extractTar as jest.Mock<any>).mockResolvedValue('/tmp/extracted');
269+
270+
await installCpythonFromRelease(makeRelease(downloadUrl));
271+
272+
const call = (tc.downloadTool as jest.Mock<any>).mock.calls[0];
273+
expect(call[0]).toBe(downloadUrl);
274+
return call[2];
275+
}
276+
277+
it('forwards token to github.com download URLs', async () => {
278+
setInputs({token: 'TKN'});
279+
await expect(
280+
downloadAuthFor(
281+
'https://github.com/actions/python-versions/releases/download/3.12.0-x/python-3.12.0-linux-x64.tar.gz'
282+
)
283+
).resolves.toBe('token TKN');
284+
});
285+
286+
it('forwards token to api.github.com download URLs', async () => {
287+
setInputs({token: 'TKN'});
288+
await expect(
289+
downloadAuthFor('https://api.github.com/repos/x/y/tarball/main')
290+
).resolves.toBe('token TKN');
291+
});
292+
293+
it('forwards token to *.githubusercontent.com download URLs', async () => {
294+
setInputs({token: 'TKN'});
295+
await expect(
296+
downloadAuthFor('https://objects.githubusercontent.com/x/python.tar.gz')
297+
).resolves.toBe('token TKN');
298+
});
299+
300+
it('does NOT forward token to a non-GitHub download URL', async () => {
301+
setInputs({token: 'TKN', mirror: 'https://cdn.example'});
302+
await expect(
303+
downloadAuthFor('https://cdn.example/py.tar.gz')
304+
).resolves.toBeUndefined();
305+
});
306+
307+
it('does NOT forward token to a lookalike host', async () => {
308+
setInputs({token: 'TKN', mirror: 'https://evil-github.com'});
309+
await expect(
310+
downloadAuthFor('https://evil-github.com/py.tar.gz')
311+
).resolves.toBeUndefined();
312+
});
313+
314+
it('forwards mirror-token to a non-GitHub download URL', async () => {
315+
setInputs({
316+
token: 'TKN',
317+
'mirror-token': 'MTOK',
318+
mirror: 'https://cdn.example'
319+
});
320+
await expect(
321+
downloadAuthFor('https://cdn.example/py.tar.gz')
322+
).resolves.toBe('token MTOK');
323+
});
324+
325+
it('prefers mirror-token over token for GitHub download URLs', async () => {
326+
setInputs({token: 'TKN', 'mirror-token': 'MTOK'});
327+
await expect(
328+
downloadAuthFor('https://github.com/o/r/releases/download/v/py.tar.gz')
329+
).resolves.toBe('token MTOK');
330+
});
331+
332+
it('sends no auth when no tokens are configured', async () => {
333+
await expect(
334+
downloadAuthFor('https://github.com/o/r/releases/download/v/py.tar.gz')
335+
).resolves.toBeUndefined();
336+
});
337+
});

action.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,14 @@ inputs:
1616
description: "Set this option if you want the action to check for the latest available version that satisfies the version spec."
1717
default: false
1818
token:
19-
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."
19+
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."
2020
default: ${{ github.server_url == 'https://github.com' && github.token || '' }}
21+
mirror:
22+
description: "Base URL for downloading Python distributions. Defaults to https://raw.githubusercontent.com/actions/python-versions/main. See docs/advanced-usage.md for details."
23+
default: "https://raw.githubusercontent.com/actions/python-versions/main"
24+
mirror-token:
25+
description: "Token used to authenticate requests to 'mirror'. Takes precedence over 'token'."
26+
required: false
2127
cache-dependency-path:
2228
description: "Used to specify the path to dependency files. Supports wildcards or a list of file names for caching multiple dependencies."
2329
update-environment:

0 commit comments

Comments
 (0)