Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/test-python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ jobs:
- name: Run simple code
run: python -c 'import math; print(math.factorial(5))'

setup-versions-via-mirror-input:
name: 'Setup via explicit mirror input: ${{ matrix.os }}'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v6

- name: setup-python with explicit mirror
uses: ./
with:
python-version: 3.12
mirror: https://raw.githubusercontent.com/actions/python-versions/main

- name: Run simple code
run: python -c 'import sys; print(sys.version)'

setup-versions-from-file:
name: Setup ${{ matrix.python }} ${{ matrix.os }} version file
runs-on: ${{ matrix.os }}
Expand Down
233 changes: 223 additions & 10 deletions __tests__/install-python.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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();
Expand All @@ -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'
);
});
});

Expand All @@ -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'
);
});
});
8 changes: 7 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
default: false
token:
description: "The token used to authenticate when fetching Python distributions from https://github.com/actions/python-versions. When running this action on github.com, the default value is sufficient. When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting."
description: "The token used to authenticate when fetching Python distributions from https://github.com/actions/python-versions. When running this action on github.com, the default value is sufficient. When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting. When 'mirror-token' is set, it takes precedence over this input."
default: ${{ github.server_url == 'https://github.com' && github.token || '' }}
mirror:
description: "Base URL for downloading Python distributions. Defaults to https://raw.githubusercontent.com/actions/python-versions/main. See docs/advanced-usage.md for details."
default: "https://raw.githubusercontent.com/actions/python-versions/main"
mirror-token:
description: "Token used to authenticate requests to 'mirror'. Takes precedence over 'token'."
required: false
cache-dependency-path:
description: "Used to specify the path to dependency files. Supports wildcards or a list of file names for caching multiple dependencies."
update-environment:
Expand Down
Loading