Skip to content

Commit 2e3c0ac

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 28f2168 commit 2e3c0ac

File tree

8 files changed

+5034
-4634
lines changed

8 files changed

+5034
-4634
lines changed

.github/workflows/test-python.yml

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

61+
setup-versions-via-mirror-input:
62+
name: 'Setup via explicit mirror input: ${{ matrix.os }}'
63+
runs-on: ${{ matrix.os }}
64+
strategy:
65+
fail-fast: false
66+
matrix:
67+
os: [ubuntu-latest, windows-latest, macos-latest]
68+
steps:
69+
- name: Checkout
70+
uses: actions/checkout@v6
71+
72+
- name: setup-python with explicit mirror
73+
uses: ./
74+
with:
75+
python-version: 3.12
76+
mirror: https://raw.githubusercontent.com/actions/python-versions/main
77+
78+
- name: Run simple code
79+
run: python -c 'import sys; print(sys.version)'
80+
6181
setup-versions-from-file:
6282
name: Setup ${{ matrix.python }} ${{ matrix.os }} version file
6383
runs-on: ${{ matrix.os }}

__tests__/install-python.test.ts

Lines changed: 223 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
11
import {
22
getManifest,
33
getManifestFromRepo,
4-
getManifestFromURL
4+
getManifestFromURL,
5+
installCpythonFromRelease
56
} from '../src/install-python';
67
import * as httpm from '@actions/http-client';
78
import * as tc from '@actions/tool-cache';
89

910
jest.mock('@actions/http-client');
10-
jest.mock('@actions/tool-cache');
1111
jest.mock('@actions/tool-cache', () => ({
12-
getManifestFromRepo: jest.fn()
12+
getManifestFromRepo: jest.fn(),
13+
downloadTool: jest.fn(),
14+
extractTar: jest.fn(),
15+
extractZip: jest.fn(),
16+
HTTPError: class HTTPError extends Error {}
1317
}));
18+
jest.mock('@actions/exec', () => ({
19+
exec: jest.fn().mockResolvedValue(0)
20+
}));
21+
jest.mock('../src/utils', () => ({
22+
...jest.requireActual('../src/utils'),
23+
IS_WINDOWS: false,
24+
IS_LINUX: false
25+
}));
26+
1427
const mockManifest = [
1528
{
1629
version: '1.0.0',
@@ -26,11 +39,27 @@ const mockManifest = [
2639
}
2740
];
2841

29-
describe('getManifest', () => {
30-
beforeEach(() => {
31-
jest.resetAllMocks();
32-
});
42+
function setInputs(values: Record<string, string | undefined>) {
43+
for (const key of ['TOKEN', 'MIRROR', 'MIRROR-TOKEN']) {
44+
delete process.env[`INPUT_${key}`];
45+
}
46+
for (const [k, v] of Object.entries(values)) {
47+
if (v !== undefined) {
48+
process.env[`INPUT_${k.toUpperCase()}`] = v;
49+
}
50+
}
51+
}
52+
53+
beforeEach(() => {
54+
jest.resetAllMocks();
55+
setInputs({});
56+
});
3357

58+
afterAll(() => {
59+
setInputs({});
60+
});
61+
62+
describe('getManifest', () => {
3463
it('should return manifest from repo', async () => {
3564
(tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest);
3665
const manifest = await getManifest();
@@ -50,10 +79,82 @@ describe('getManifest', () => {
5079
});
5180

5281
describe('getManifestFromRepo', () => {
53-
it('should return manifest from repo', async () => {
82+
it('default mirror calls getManifestFromRepo with actions/python-versions@main and token', async () => {
83+
setInputs({token: 'TKN'});
5484
(tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest);
55-
const manifest = await getManifestFromRepo();
56-
expect(manifest).toEqual(mockManifest);
85+
await getManifestFromRepo();
86+
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
87+
'actions',
88+
'python-versions',
89+
'token TKN',
90+
'main'
91+
);
92+
});
93+
94+
it('custom raw mirror extracts owner/repo/branch and passes token', async () => {
95+
setInputs({
96+
token: 'TKN',
97+
mirror: 'https://raw.githubusercontent.com/foo/bar/dev'
98+
});
99+
(tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest);
100+
await getManifestFromRepo();
101+
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
102+
'foo',
103+
'bar',
104+
'token TKN',
105+
'dev'
106+
);
107+
});
108+
109+
it('custom non-GitHub mirror throws (caller falls through to URL fetch)', () => {
110+
setInputs({mirror: 'https://mirror.example/py'});
111+
expect(() => getManifestFromRepo()).toThrow(/not a GitHub repo URL/);
112+
});
113+
114+
it('mirror-token wins over token for the api.github.com call (getManifestFromRepo)', async () => {
115+
setInputs({
116+
token: 'TKN',
117+
'mirror-token': 'MTOK',
118+
mirror: 'https://raw.githubusercontent.com/foo/bar/main'
119+
});
120+
(tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest);
121+
await getManifestFromRepo();
122+
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
123+
'foo',
124+
'bar',
125+
'token MTOK',
126+
'main'
127+
);
128+
});
129+
130+
it('token is used when mirror-token is empty (getManifestFromRepo)', async () => {
131+
setInputs({
132+
token: 'TKN',
133+
mirror: 'https://raw.githubusercontent.com/foo/bar/main'
134+
});
135+
(tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest);
136+
await getManifestFromRepo();
137+
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
138+
'foo',
139+
'bar',
140+
'token TKN',
141+
'main'
142+
);
143+
});
144+
145+
it('trailing slashes in mirror URL are stripped', async () => {
146+
setInputs({
147+
token: 'TKN',
148+
mirror: 'https://raw.githubusercontent.com/foo/bar/main/'
149+
});
150+
(tc.getManifestFromRepo as jest.Mock).mockResolvedValue(mockManifest);
151+
await getManifestFromRepo();
152+
expect(tc.getManifestFromRepo).toHaveBeenCalledWith(
153+
'foo',
154+
'bar',
155+
'token TKN',
156+
'main'
157+
);
57158
});
58159
});
59160

@@ -74,4 +175,116 @@ describe('getManifestFromURL', () => {
74175
'Unable to get manifest from'
75176
);
76177
});
178+
179+
it('fetches from {mirror}/versions-manifest.json (no auth header attached)', async () => {
180+
setInputs({token: 'TKN', mirror: 'https://mirror.example/py'});
181+
(httpm.HttpClient.prototype.getJson as jest.Mock).mockResolvedValue({
182+
result: mockManifest
183+
});
184+
await getManifestFromURL();
185+
expect(httpm.HttpClient.prototype.getJson).toHaveBeenCalledWith(
186+
'https://mirror.example/py/versions-manifest.json'
187+
);
188+
});
189+
});
190+
191+
describe('mirror URL validation', () => {
192+
it('throws on invalid URL when used', () => {
193+
setInputs({mirror: 'not a url'});
194+
expect(() => getManifestFromRepo()).toThrow(/Invalid 'mirror' URL/);
195+
});
196+
});
197+
198+
describe('installCpythonFromRelease auth gating', () => {
199+
const makeRelease = (downloadUrl: string) =>
200+
({
201+
version: '3.12.0',
202+
stable: true,
203+
release_url: '',
204+
files: [
205+
{
206+
filename: 'python-3.12.0-linux-x64.tar.gz',
207+
platform: 'linux',
208+
platform_version: '',
209+
arch: 'x64',
210+
download_url: downloadUrl
211+
}
212+
]
213+
}) as any;
214+
215+
function stubInstallExtract() {
216+
(tc.downloadTool as jest.Mock).mockResolvedValue('/tmp/py.tgz');
217+
(tc.extractTar as jest.Mock).mockResolvedValue('/tmp/extracted');
218+
}
219+
220+
it('forwards token to github.com download URLs', async () => {
221+
setInputs({token: 'TKN'});
222+
stubInstallExtract();
223+
await installCpythonFromRelease(
224+
makeRelease(
225+
'https://github.com/actions/python-versions/releases/download/3.12.0-x/python-3.12.0-linux-x64.tar.gz'
226+
)
227+
);
228+
expect(tc.downloadTool).toHaveBeenCalledWith(
229+
expect.any(String),
230+
undefined,
231+
'token TKN'
232+
);
233+
});
234+
235+
it('forwards token to api.github.com URLs', async () => {
236+
setInputs({token: 'TKN'});
237+
stubInstallExtract();
238+
await installCpythonFromRelease(
239+
makeRelease('https://api.github.com/repos/x/y/tarball/main')
240+
);
241+
expect(tc.downloadTool).toHaveBeenCalledWith(
242+
expect.any(String),
243+
undefined,
244+
'token TKN'
245+
);
246+
});
247+
248+
it('forwards token to objects.githubusercontent.com download URLs', async () => {
249+
setInputs({token: 'TKN'});
250+
stubInstallExtract();
251+
await installCpythonFromRelease(
252+
makeRelease('https://objects.githubusercontent.com/x/python.tar.gz')
253+
);
254+
expect(tc.downloadTool).toHaveBeenCalledWith(
255+
expect.any(String),
256+
undefined,
257+
'token TKN'
258+
);
259+
});
260+
261+
it('does NOT forward token to non-GitHub download URLs', async () => {
262+
setInputs({token: 'TKN'});
263+
stubInstallExtract();
264+
await installCpythonFromRelease(
265+
makeRelease('https://cdn.example/py.tar.gz')
266+
);
267+
expect(tc.downloadTool).toHaveBeenCalledWith(
268+
expect.any(String),
269+
undefined,
270+
undefined
271+
);
272+
});
273+
274+
it('forwards mirror-token to non-GitHub download URLs', async () => {
275+
setInputs({
276+
token: 'TKN',
277+
'mirror-token': 'MTOK',
278+
mirror: 'https://cdn.example'
279+
});
280+
stubInstallExtract();
281+
await installCpythonFromRelease(
282+
makeRelease('https://cdn.example/py.tar.gz')
283+
);
284+
expect(tc.downloadTool).toHaveBeenCalledWith(
285+
expect.any(String),
286+
undefined,
287+
'token MTOK'
288+
);
289+
});
77290
});

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)