mirror of
https://github.com/actions/setup-python.git
synced 2026-08-22 08:23:04 +00:00
Merge 0d1135ac814405808323d2060134097ba5fd641b into 751276eafca55f10f252026015f43c488f2322ba
This commit is contained in:
commit
0fc1ab83c4
23
.github/workflows/test-python.yml
vendored
23
.github/workflows/test-python.yml
vendored
@ -61,6 +61,29 @@ 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
|
||||||
|
|
||||||
|
# The refs/heads/ form serves the same manifest as the default mirror but
|
||||||
|
# deliberately does not match {owner}/{repo}/{branch}, so this exercises
|
||||||
|
# the direct-URL manifest fetch that the default coordinates skip.
|
||||||
|
- name: setup-python with explicit mirror
|
||||||
|
uses: ./
|
||||||
|
with:
|
||||||
|
python-version: 3.12
|
||||||
|
mirror: https://raw.githubusercontent.com/actions/python-versions/refs/heads/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 }}
|
||||||
|
|||||||
476
__tests__/install-python-mirror.test.ts
Normal file
476
__tests__/install-python-mirror.test.ts
Normal file
@ -0,0 +1,476 @@
|
|||||||
|
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,
|
||||||
|
getManifest,
|
||||||
|
getManifestFromRepo,
|
||||||
|
getManifestFromURL,
|
||||||
|
resolveRepoCoords,
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps throwing the same error when called repeatedly', () => {
|
||||||
|
setInputs({mirror: 'not a url'});
|
||||||
|
expect(() => getManifestUrl()).toThrow(/Invalid 'mirror' URL/);
|
||||||
|
// Memoized, so the second call must not silently succeed or change shape —
|
||||||
|
// find-python.ts calls this while building the "version not found" message.
|
||||||
|
expect(() => getManifestUrl()).toThrow(/Invalid 'mirror' URL/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveRepoCoords', () => {
|
||||||
|
it('warns and returns null for a raw.githubusercontent.com mirror with a slash in the branch', () => {
|
||||||
|
setInputs({
|
||||||
|
mirror: 'https://raw.githubusercontent.com/foo/bar/feature/riscv'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveRepoCoords()).toBeNull();
|
||||||
|
expect(core.warning).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/Branch names containing '\/' are not supported/)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn for a non-GitHub mirror', () => {
|
||||||
|
setInputs({mirror: 'https://mirror.example/py'});
|
||||||
|
|
||||||
|
expect(resolveRepoCoords()).toBeNull();
|
||||||
|
expect(core.warning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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('returns null for a non-GitHub mirror so the caller uses the raw URL', () => {
|
||||||
|
setInputs({mirror: 'https://mirror.example/py'});
|
||||||
|
expect(resolveRepoCoords()).toBeNull();
|
||||||
|
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();
|
||||||
|
|
||||||
|
// The API requires the `token ` prefix, and naming a repo mirror is explicit intent to
|
||||||
|
// read that repo, so mirror-token is prefixed here even though downloads send it verbatim.
|
||||||
|
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 auth when no mirror-token is set', 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();
|
||||||
|
|
||||||
|
// `token` must not reach a non-GitHub mirror.
|
||||||
|
expect(getJson).toHaveBeenCalledWith(
|
||||||
|
'https://mirror.example/py/versions-manifest.json',
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends mirror-token verbatim on the manifest fetch', async () => {
|
||||||
|
setInputs({
|
||||||
|
token: 'TKN',
|
||||||
|
'mirror-token': 'Bearer MTOK',
|
||||||
|
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',
|
||||||
|
{authorization: 'Bearer MTOK'}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends token as a prefixed header for a GitHub-hosted raw manifest', async () => {
|
||||||
|
setInputs({
|
||||||
|
token: 'TKN',
|
||||||
|
mirror: 'https://raw.githubusercontent.com/foo/bar/refs/heads/main'
|
||||||
|
});
|
||||||
|
const getJson = jest.fn(async () => ({result: mockManifest}));
|
||||||
|
(httpm.HttpClient as jest.Mock<any>).mockImplementation(() => ({getJson}));
|
||||||
|
|
||||||
|
await getManifestFromURL();
|
||||||
|
|
||||||
|
expect(getJson).toHaveBeenCalledWith(
|
||||||
|
'https://raw.githubusercontent.com/foo/bar/refs/heads/main/versions-manifest.json',
|
||||||
|
{authorization: 'token TKN'}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getManifest source routing', () => {
|
||||||
|
it('skips the GitHub API entirely for a non-GitHub mirror', async () => {
|
||||||
|
setInputs({mirror: 'https://mirror.example/py'});
|
||||||
|
const getJson = jest.fn(async () => ({result: mockManifest}));
|
||||||
|
(httpm.HttpClient as jest.Mock<any>).mockImplementation(() => ({getJson}));
|
||||||
|
|
||||||
|
await expect(getManifest()).resolves.toEqual(mockManifest);
|
||||||
|
|
||||||
|
// Routing straight to the URL fetch avoids 3 retries with backoff on a
|
||||||
|
// call that could never succeed.
|
||||||
|
expect(tc.getManifestFromRepo).not.toHaveBeenCalled();
|
||||||
|
expect(getJson).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the GitHub API for a repo mirror without touching the raw URL', async () => {
|
||||||
|
setInputs({token: 'TKN'});
|
||||||
|
(tc.getManifestFromRepo as jest.Mock<any>).mockResolvedValue(mockManifest);
|
||||||
|
const getJson = jest.fn(async () => ({result: mockManifest}));
|
||||||
|
(httpm.HttpClient as jest.Mock<any>).mockImplementation(() => ({getJson}));
|
||||||
|
|
||||||
|
await expect(getManifest()).resolves.toEqual(mockManifest);
|
||||||
|
|
||||||
|
expect(tc.getManifestFromRepo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getJson).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 verbatim to the mirror host', async () => {
|
||||||
|
setInputs({
|
||||||
|
token: 'TKN',
|
||||||
|
'mirror-token': 'Bearer MTOK',
|
||||||
|
mirror: 'https://cdn.example'
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
downloadAuthFor('https://cdn.example/py.tar.gz')
|
||||||
|
).resolves.toBe('Bearer MTOK');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not prefix or rewrite a mirror-token', async () => {
|
||||||
|
setInputs({
|
||||||
|
'mirror-token': 'Basic dXNlcjpwYXNz',
|
||||||
|
mirror: 'https://cdn.example'
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
downloadAuthFor('https://cdn.example/py.tar.gz')
|
||||||
|
).resolves.toBe('Basic dXNlcjpwYXNz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('withholds mirror-token from an incidental GitHub host and uses token there', async () => {
|
||||||
|
setInputs({
|
||||||
|
token: 'TKN',
|
||||||
|
'mirror-token': 'MTOK',
|
||||||
|
mirror: 'https://cdn.example'
|
||||||
|
});
|
||||||
|
// A manifest hosted on the private mirror may still point release assets at
|
||||||
|
// GitHub; the private credential must not follow them there.
|
||||||
|
await expect(
|
||||||
|
downloadAuthFor('https://objects.githubusercontent.com/x/python.tar.gz')
|
||||||
|
).resolves.toBe('token TKN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('withholds mirror-token from a GitHub host when no token is set', async () => {
|
||||||
|
setInputs({'mirror-token': 'MTOK', mirror: 'https://cdn.example'});
|
||||||
|
await expect(
|
||||||
|
downloadAuthFor('https://objects.githubusercontent.com/x/python.tar.gz')
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('withholds mirror-token from a third host that is neither the mirror nor GitHub', async () => {
|
||||||
|
setInputs({
|
||||||
|
token: 'TKN',
|
||||||
|
'mirror-token': 'MTOK',
|
||||||
|
mirror: 'https://cdn.example'
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
downloadAuthFor('https://other.example/py.tar.gz')
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses mirror-token for a GitHub mirror host when it is the nominated host', async () => {
|
||||||
|
setInputs({
|
||||||
|
token: 'TKN',
|
||||||
|
'mirror-token': 'token MTOK',
|
||||||
|
mirror: 'https://raw.githubusercontent.com/foo/bar/main'
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
downloadAuthFor('https://raw.githubusercontent.com/foo/bar/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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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. This token is only sent to GitHub-owned hosts, never to a custom 'mirror'."
|
||||||
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 (only applies to CPython; PyPy and GraalPy are unaffected). 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 the host named in 'mirror'. Sent verbatim as the Authorization header, so include a scheme if your mirror needs one (e.g. 'Bearer <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:
|
||||||
|
|||||||
175
dist/setup/index.js
vendored
175
dist/setup/index.js
vendored
@ -98843,12 +98843,96 @@ 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');
|
||||||
|
}
|
||||||
|
// Memoized per raw input value so the mirror is validated once per run rather
|
||||||
|
// than on every call. `getManifestUrl()` is also used to build the "version not
|
||||||
|
// found" message in find-python.ts, where re-validating would replace the real
|
||||||
|
// cause with an invalid-mirror error.
|
||||||
|
const mirrorCache = new Map();
|
||||||
|
function getMirror() {
|
||||||
|
const input = getInput('mirror') || DEFAULT_MIRROR;
|
||||||
|
let resolved = mirrorCache.get(input);
|
||||||
|
if (!resolved) {
|
||||||
|
const url = input.trim().replace(/\/+$/, '');
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
resolved = { url };
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
resolved = { error: new Error(`Invalid 'mirror' URL: "${url}"`) };
|
||||||
|
}
|
||||||
|
mirrorCache.set(input, resolved);
|
||||||
|
}
|
||||||
|
if ('error' in resolved)
|
||||||
|
throw resolved.error;
|
||||||
|
return resolved.url;
|
||||||
|
}
|
||||||
|
function getManifestUrl() {
|
||||||
|
return `${getMirror()}/versions-manifest.json`;
|
||||||
|
}
|
||||||
|
function getMirrorHost() {
|
||||||
|
try {
|
||||||
|
return new URL(getMirror()).host;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function isGitHubHost(host) {
|
||||||
|
return (host === 'github.com' ||
|
||||||
|
host.endsWith('.github.com') ||
|
||||||
|
host.endsWith('.githubusercontent.com'));
|
||||||
|
}
|
||||||
|
// Warned at most once per distinct mirror; resolveRepoCoords() is called from
|
||||||
|
// several paths within a single run.
|
||||||
|
const warnedMirrors = new Set();
|
||||||
|
function resolveRepoCoords() {
|
||||||
|
const mirror = getMirror();
|
||||||
|
const m = REPO_COORDS_RE.exec(mirror);
|
||||||
|
if (m)
|
||||||
|
return { owner: m[1], repo: m[2], branch: m[3] };
|
||||||
|
// A raw.githubusercontent.com URL that doesn't parse is usually a branch
|
||||||
|
// name containing a slash, which is indistinguishable from a deeper path.
|
||||||
|
// Fetching still works, just anonymously and without the API rate limit.
|
||||||
|
if (!warnedMirrors.has(mirror) &&
|
||||||
|
getMirrorHost() === 'raw.githubusercontent.com') {
|
||||||
|
warnedMirrors.add(mirror);
|
||||||
|
warning(`Could not parse owner/repo/branch out of mirror "${mirror}", so the manifest will be fetched by direct URL instead of the GitHub API. ` +
|
||||||
|
`Branch names containing '/' are not supported; use a branch without a slash to get the authenticated API rate limit.`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Mirror host with `mirror-token` set gets the token verbatim, so internal
|
||||||
|
// mirrors can choose their own scheme (Bearer, Basic, ...). GitHub hosts get
|
||||||
|
// `token ${token}`. Anything else is anonymous — neither credential is sent to
|
||||||
|
// a host the user didn't nominate.
|
||||||
|
function authForUrl(url) {
|
||||||
|
let host;
|
||||||
|
try {
|
||||||
|
host = new URL(url).host;
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const mirrorToken = getMirrorToken();
|
||||||
|
if (mirrorToken && host === getMirrorHost())
|
||||||
|
return mirrorToken;
|
||||||
|
const token = getToken();
|
||||||
|
if (token && isGitHubHost(host))
|
||||||
|
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');
|
||||||
@ -98974,17 +99058,25 @@ async function fetchValidManifest(source, fetcher) {
|
|||||||
throw new Error(`Failed to fetch a valid manifest from ${source} after ${attempts} attempt(s): ${lastError?.message}`);
|
throw new Error(`Failed to fetch a valid manifest from ${source} after ${attempts} attempt(s): ${lastError?.message}`);
|
||||||
}
|
}
|
||||||
async function getManifest() {
|
async function getManifest() {
|
||||||
try {
|
// Only GitHub repo mirrors can be fetched via the API. Checking up front
|
||||||
return await fetchValidManifest('the GitHub API', install_python_getManifestFromRepo);
|
// avoids burning MANIFEST_FETCH_MAX_ATTEMPTS with backoff on a throw that
|
||||||
|
// could never succeed.
|
||||||
|
if (resolveRepoCoords()) {
|
||||||
|
try {
|
||||||
|
return await fetchValidManifest('the GitHub API', install_python_getManifestFromRepo);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
core_debug('Fetching the manifest via the API failed.');
|
||||||
|
if (err instanceof Error) {
|
||||||
|
core_debug(err.message);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
core_debug('An unexpected error occurred while fetching the manifest.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
else {
|
||||||
core_debug('Fetching the manifest via the API failed.');
|
core_debug(`Mirror "${getMirror()}" is not a GitHub repo URL; fetching the manifest by URL.`);
|
||||||
if (err instanceof Error) {
|
|
||||||
core_debug(err.message);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
core_debug('An unexpected error occurred while fetching the manifest.');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await fetchValidManifest('the raw URL', getManifestFromURL);
|
return await fetchValidManifest('the raw URL', getManifestFromURL);
|
||||||
@ -98996,15 +99088,32 @@ 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}`);
|
||||||
|
// This only runs for GitHub repo mirrors, where `mirror-token` is the user's
|
||||||
|
// explicit intent for that repo. The target is always api.github.com, which
|
||||||
|
// requires the `token ` prefix, so the host rule in authForUrl() doesn't
|
||||||
|
// apply here.
|
||||||
|
const token = getToken();
|
||||||
|
const mirrorToken = getMirrorToken();
|
||||||
|
const auth = mirrorToken
|
||||||
|
? `token ${mirrorToken}`
|
||||||
|
: token
|
||||||
|
? `token ${token}`
|
||||||
|
: undefined;
|
||||||
|
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 auth = authForUrl(manifestUrl);
|
||||||
|
const response = await http.getJson(manifestUrl, auth ? { authorization: auth } : undefined);
|
||||||
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;
|
||||||
}
|
}
|
||||||
@ -99047,7 +99156,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) {
|
||||||
@ -99165,7 +99274,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);
|
||||||
@ -99567,8 +99676,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());
|
||||||
@ -99591,7 +99700,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);
|
||||||
@ -99632,8 +99741,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.
|
||||||
@ -103614,6 +103723,16 @@ function isPyPyVersion(versionSpec) {
|
|||||||
function isGraalPyVersion(versionSpec) {
|
function isGraalPyVersion(versionSpec) {
|
||||||
return versionSpec.startsWith('graalpy');
|
return versionSpec.startsWith('graalpy');
|
||||||
}
|
}
|
||||||
|
// `mirror` only redirects CPython distributions. PyPy and GraalPy resolve from
|
||||||
|
// downloads.python.org and the GitHub releases API respectively, so warn rather
|
||||||
|
// than let the input look like it applied.
|
||||||
|
function warnIfMirrorUnsupported(versionSpec) {
|
||||||
|
if (!getInput('mirror')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const implementation = isPyPyVersion(versionSpec) ? 'PyPy' : 'GraalPy';
|
||||||
|
warning(`The 'mirror' input only applies to CPython distributions and is ignored for ${implementation} ('${versionSpec}'), which is downloaded from its own upstream source.`);
|
||||||
|
}
|
||||||
async function cacheDependencies(cache, pythonVersion) {
|
async function cacheDependencies(cache, pythonVersion) {
|
||||||
const cacheDependencyPath = getInput('cache-dependency-path') || undefined;
|
const cacheDependencyPath = getInput('cache-dependency-path') || undefined;
|
||||||
const cacheDistributor = getCacheDistributor(cache, pythonVersion, cacheDependencyPath);
|
const cacheDistributor = getCacheDistributor(cache, pythonVersion, cacheDependencyPath);
|
||||||
@ -103675,11 +103794,13 @@ async function run() {
|
|||||||
startGroup('Installed versions');
|
startGroup('Installed versions');
|
||||||
for (const version of versions) {
|
for (const version of versions) {
|
||||||
if (isPyPyVersion(version)) {
|
if (isPyPyVersion(version)) {
|
||||||
|
warnIfMirrorUnsupported(version);
|
||||||
const installed = await findPyPyVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases);
|
const installed = await findPyPyVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases);
|
||||||
pythonVersion = `${installed.resolvedPyPyVersion}-${installed.resolvedPythonVersion}`;
|
pythonVersion = `${installed.resolvedPyPyVersion}-${installed.resolvedPythonVersion}`;
|
||||||
info(`Successfully set up PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})`);
|
info(`Successfully set up PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})`);
|
||||||
}
|
}
|
||||||
else if (isGraalPyVersion(version)) {
|
else if (isGraalPyVersion(version)) {
|
||||||
|
warnIfMirrorUnsupported(version);
|
||||||
const installed = await findGraalPyVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases);
|
const installed = await findGraalPyVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases);
|
||||||
pythonVersion = `${installed}`;
|
pythonVersion = `${installed}`;
|
||||||
info(`Successfully set up GraalPy ${installed}`);
|
info(`Successfully set up GraalPy ${installed}`);
|
||||||
|
|||||||
@ -524,6 +524,46 @@ 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 is decided by the host of each request, so neither credential reaches a server you did not nominate:
|
||||||
|
|
||||||
|
- Requests to the host named in `mirror` use `mirror-token`, sent **verbatim** as the `Authorization` header. Include a scheme if your mirror expects one — `Bearer <token>`, `Basic <base64>`, or `token <token>` for a GitHub host. This covers both the manifest fetch and the tarball downloads.
|
||||||
|
- Requests to `github.com`, `*.github.com`, or `*.githubusercontent.com` use `token`, sent as `token <token>`. A manifest that points its `download_url` at a GitHub host therefore keeps working without `mirror-token` being leaked to it.
|
||||||
|
- Any other host is requested anonymously.
|
||||||
|
- One exception: when `mirror` is a GitHub repo URL, the manifest is fetched from `api.github.com`, and `mirror-token` is preferred there (with the `token ` prefix the API requires) because naming a repo mirror is an explicit instruction to read that repo.
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Caveats:
|
||||||
|
|
||||||
|
- `mirror` and `mirror-token` apply to **CPython only**. PyPy resolves from `downloads.python.org` and GraalPy from the GitHub releases API; both ignore these inputs, and the action warns if you set `mirror` alongside a `pypy-*` or `graalpy-*` version.
|
||||||
|
- Branch names containing `/` cannot be used with a `raw.githubusercontent.com` mirror, because `.../{owner}/{repo}/feature/riscv` is indistinguishable from a repo path. Such a mirror still works, but falls back to an anonymous direct GET with the 60/hr unauthenticated rate limit; the action warns when this happens. Use a branch without a slash to get the API path.
|
||||||
|
|
||||||
### PyPy
|
### PyPy
|
||||||
|
|
||||||
`setup-python` is able to configure **PyPy** from two sources:
|
`setup-python` is able to configure **PyPy** from two sources:
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,12 +9,118 @@ 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoized per raw input value so the mirror is validated once per run rather
|
||||||
|
// than on every call. `getManifestUrl()` is also used to build the "version not
|
||||||
|
// found" message in find-python.ts, where re-validating would replace the real
|
||||||
|
// cause with an invalid-mirror error.
|
||||||
|
const mirrorCache = new Map<string, {url: string} | {error: Error}>();
|
||||||
|
|
||||||
|
function getMirror(): string {
|
||||||
|
const input = core.getInput('mirror') || DEFAULT_MIRROR;
|
||||||
|
let resolved = mirrorCache.get(input);
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
const url = input.trim().replace(/\/+$/, '');
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
resolved = {url};
|
||||||
|
} catch {
|
||||||
|
resolved = {error: new Error(`Invalid 'mirror' URL: "${url}"`)};
|
||||||
|
}
|
||||||
|
mirrorCache.set(input, resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('error' in resolved) throw resolved.error;
|
||||||
|
return resolved.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getManifestUrl(): string {
|
||||||
|
return `${getMirror()}/versions-manifest.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMirrorHost(): string | undefined {
|
||||||
|
try {
|
||||||
|
return new URL(getMirror()).host;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitHubHost(host: string): boolean {
|
||||||
|
return (
|
||||||
|
host === 'github.com' ||
|
||||||
|
host.endsWith('.github.com') ||
|
||||||
|
host.endsWith('.githubusercontent.com')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warned at most once per distinct mirror; resolveRepoCoords() is called from
|
||||||
|
// several paths within a single run.
|
||||||
|
const warnedMirrors = new Set<string>();
|
||||||
|
|
||||||
|
export function resolveRepoCoords(): {
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
branch: string;
|
||||||
|
} | null {
|
||||||
|
const mirror = getMirror();
|
||||||
|
const m = REPO_COORDS_RE.exec(mirror);
|
||||||
|
if (m) return {owner: m[1], repo: m[2], branch: m[3]};
|
||||||
|
|
||||||
|
// A raw.githubusercontent.com URL that doesn't parse is usually a branch
|
||||||
|
// name containing a slash, which is indistinguishable from a deeper path.
|
||||||
|
// Fetching still works, just anonymously and without the API rate limit.
|
||||||
|
if (
|
||||||
|
!warnedMirrors.has(mirror) &&
|
||||||
|
getMirrorHost() === 'raw.githubusercontent.com'
|
||||||
|
) {
|
||||||
|
warnedMirrors.add(mirror);
|
||||||
|
core.warning(
|
||||||
|
`Could not parse owner/repo/branch out of mirror "${mirror}", so the manifest will be fetched by direct URL instead of the GitHub API. ` +
|
||||||
|
`Branch names containing '/' are not supported; use a branch without a slash to get the authenticated API rate limit.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror host with `mirror-token` set gets the token verbatim, so internal
|
||||||
|
// mirrors can choose their own scheme (Bearer, Basic, ...). GitHub hosts get
|
||||||
|
// `token ${token}`. Anything else is anonymous — neither credential is sent to
|
||||||
|
// a host the user didn't nominate.
|
||||||
|
function authForUrl(url: string): string | undefined {
|
||||||
|
let host: string;
|
||||||
|
try {
|
||||||
|
host = new URL(url).host;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mirrorToken = getMirrorToken();
|
||||||
|
if (mirrorToken && host === getMirrorHost()) return mirrorToken;
|
||||||
|
|
||||||
|
const token = getToken();
|
||||||
|
if (token && isGitHubHost(host)) return `token ${token}`;
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
interface LinuxOsRelease {
|
interface LinuxOsRelease {
|
||||||
id: string;
|
id: string;
|
||||||
@ -205,15 +311,24 @@ async function fetchValidManifest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getManifest(): Promise<tc.IToolRelease[]> {
|
export async function getManifest(): Promise<tc.IToolRelease[]> {
|
||||||
try {
|
// Only GitHub repo mirrors can be fetched via the API. Checking up front
|
||||||
return await fetchValidManifest('the GitHub API', getManifestFromRepo);
|
// avoids burning MANIFEST_FETCH_MAX_ATTEMPTS with backoff on a throw that
|
||||||
} catch (err) {
|
// could never succeed.
|
||||||
core.debug('Fetching the manifest via the API failed.');
|
if (resolveRepoCoords()) {
|
||||||
if (err instanceof Error) {
|
try {
|
||||||
core.debug(err.message);
|
return await fetchValidManifest('the GitHub API', getManifestFromRepo);
|
||||||
} else {
|
} catch (err) {
|
||||||
core.debug('An unexpected error occurred while fetching the manifest.');
|
core.debug('Fetching the manifest via the API failed.');
|
||||||
|
if (err instanceof Error) {
|
||||||
|
core.debug(err.message);
|
||||||
|
} else {
|
||||||
|
core.debug('An unexpected error occurred while fetching the manifest.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
core.debug(
|
||||||
|
`Mirror "${getMirror()}" is not a GitHub repo URL; fetching the manifest by URL.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -229,24 +344,41 @@ 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
|
|
||||||
);
|
);
|
||||||
|
// This only runs for GitHub repo mirrors, where `mirror-token` is the user's
|
||||||
|
// explicit intent for that repo. The target is always api.github.com, which
|
||||||
|
// requires the `token ` prefix, so the host rule in authForUrl() doesn't
|
||||||
|
// apply here.
|
||||||
|
const token = getToken();
|
||||||
|
const mirrorToken = getMirrorToken();
|
||||||
|
const auth = mirrorToken
|
||||||
|
? `token ${mirrorToken}`
|
||||||
|
: token
|
||||||
|
? `token ${token}`
|
||||||
|
: undefined;
|
||||||
|
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 auth = authForUrl(manifestUrl);
|
||||||
|
const response = await http.getJson<tc.IToolRelease[]>(
|
||||||
|
manifestUrl,
|
||||||
|
auth ? {authorization: auth} : undefined
|
||||||
|
);
|
||||||
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 +423,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) {
|
||||||
|
|||||||
@ -23,6 +23,19 @@ function isGraalPyVersion(versionSpec: string) {
|
|||||||
return versionSpec.startsWith('graalpy');
|
return versionSpec.startsWith('graalpy');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `mirror` only redirects CPython distributions. PyPy and GraalPy resolve from
|
||||||
|
// downloads.python.org and the GitHub releases API respectively, so warn rather
|
||||||
|
// than let the input look like it applied.
|
||||||
|
function warnIfMirrorUnsupported(versionSpec: string) {
|
||||||
|
if (!core.getInput('mirror')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const implementation = isPyPyVersion(versionSpec) ? 'PyPy' : 'GraalPy';
|
||||||
|
core.warning(
|
||||||
|
`The 'mirror' input only applies to CPython distributions and is ignored for ${implementation} ('${versionSpec}'), which is downloaded from its own upstream source.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function cacheDependencies(cache: string, pythonVersion: string) {
|
async function cacheDependencies(cache: string, pythonVersion: string) {
|
||||||
const cacheDependencyPath =
|
const cacheDependencyPath =
|
||||||
core.getInput('cache-dependency-path') || undefined;
|
core.getInput('cache-dependency-path') || undefined;
|
||||||
@ -102,6 +115,7 @@ async function run() {
|
|||||||
core.startGroup('Installed versions');
|
core.startGroup('Installed versions');
|
||||||
for (const version of versions) {
|
for (const version of versions) {
|
||||||
if (isPyPyVersion(version)) {
|
if (isPyPyVersion(version)) {
|
||||||
|
warnIfMirrorUnsupported(version);
|
||||||
const installed = await finderPyPy.findPyPyVersion(
|
const installed = await finderPyPy.findPyPyVersion(
|
||||||
version,
|
version,
|
||||||
arch,
|
arch,
|
||||||
@ -114,6 +128,7 @@ async function run() {
|
|||||||
`Successfully set up PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})`
|
`Successfully set up PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})`
|
||||||
);
|
);
|
||||||
} else if (isGraalPyVersion(version)) {
|
} else if (isGraalPyVersion(version)) {
|
||||||
|
warnIfMirrorUnsupported(version);
|
||||||
const installed = await finderGraalPy.findGraalPyVersion(
|
const installed = await finderGraalPy.findGraalPyVersion(
|
||||||
version,
|
version,
|
||||||
arch,
|
arch,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user