fix: address mirror review feedback

- scope mirror-token to the mirror host and send it verbatim
- route non-repo mirrors straight to the URL fetch instead of throwing
- authenticate the manifest fetch
- warn on slash branches, and on mirror with PyPy/GraalPy
- memoize mirror validation
- exercise the direct-URL path in the E2E job

Addresses https://github.com/actions/setup-python/pull/1302#issuecomment-5202618946

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ludovic Henry 2026-08-14 18:10:29 +02:00
parent f30f2fee26
commit 0d1135ac81
No known key found for this signature in database
7 changed files with 383 additions and 90 deletions

View File

@ -72,11 +72,14 @@ jobs:
- 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/main
mirror: https://raw.githubusercontent.com/actions/python-versions/refs/heads/main
- name: Run simple code
run: python -c 'import sys; print(sys.version)'

View File

@ -82,8 +82,10 @@ 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');
@ -142,6 +144,34 @@ describe('getManifestUrl', () => {
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', () => {
@ -193,9 +223,9 @@ describe('getManifestFromRepo mirror resolution', () => {
);
});
it('throws for a non-GitHub mirror so the caller falls back to the raw URL', () => {
it('returns null for a non-GitHub mirror so the caller uses the raw URL', () => {
setInputs({mirror: 'https://mirror.example/py'});
expect(() => getManifestFromRepo()).toThrow(/not a GitHub repo URL/);
expect(resolveRepoCoords()).toBeNull();
expect(tc.getManifestFromRepo).not.toHaveBeenCalled();
});
@ -209,6 +239,8 @@ describe('getManifestFromRepo mirror resolution', () => {
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',
@ -232,17 +264,79 @@ describe('getManifestFromRepo mirror resolution', () => {
});
describe('getManifestFromURL mirror resolution', () => {
it('fetches {mirror}/versions-manifest.json without attaching auth', async () => {
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'
'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', () => {
@ -311,21 +405,66 @@ describe('installCpythonFromRelease auth gating', () => {
).resolves.toBeUndefined();
});
it('forwards mirror-token to a non-GitHub download URL', async () => {
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://cdn.example/py.tar.gz')
).resolves.toBe('token MTOK');
downloadAuthFor('https://other.example/py.tar.gz')
).resolves.toBeUndefined();
});
it('prefers mirror-token over token for GitHub download URLs', async () => {
setInputs({token: 'TKN', 'mirror-token': 'MTOK'});
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://github.com/o/r/releases/download/v/py.tar.gz')
downloadAuthFor('https://raw.githubusercontent.com/foo/bar/py.tar.gz')
).resolves.toBe('token MTOK');
});

View File

@ -16,13 +16,13 @@ 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. When 'mirror-token' is set, it takes precedence over this input."
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 || '' }}
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."
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 'mirror'. Takes precedence over '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:
description: "Used to specify the path to dependency files. Supports wildcards or a list of file names for caching multiple dependencies."

136
dist/setup/index.js vendored
View File

@ -98705,29 +98705,69 @@ function getToken() {
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 raw = (getInput('mirror') || DEFAULT_MIRROR)
.trim()
.replace(/\/+$/, '');
try {
new URL(raw);
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);
}
catch {
throw new Error(`Invalid 'mirror' URL: "${raw}"`);
}
return raw;
if ('error' in resolved)
throw resolved.error;
return resolved.url;
}
function getManifestUrl() {
return `${getMirror()}/versions-manifest.json`;
}
function resolveRepoCoords() {
const m = REPO_COORDS_RE.exec(getMirror());
return m ? { owner: m[1], repo: m[2], branch: m[3] } : null;
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) {
const mirrorToken = getMirrorToken();
if (mirrorToken)
return `token ${mirrorToken}`;
let host;
try {
host = new URL(url).host;
@ -98735,11 +98775,11 @@ function authForUrl(url) {
catch {
return undefined;
}
const mirrorToken = getMirrorToken();
if (mirrorToken && host === getMirrorHost())
return mirrorToken;
const token = getToken();
if (token &&
(host === 'github.com' ||
host.endsWith('.github.com') ||
host.endsWith('.githubusercontent.com')))
if (token && isGitHubHost(host))
return `token ${token}`;
return undefined;
}
@ -98868,17 +98908,25 @@ async function fetchValidManifest(source, fetcher) {
throw new Error(`Failed to fetch a valid manifest from ${source} after ${attempts} attempt(s): ${lastError?.message}`);
}
async function getManifest() {
try {
return await fetchValidManifest('the GitHub API', install_python_getManifestFromRepo);
// Only GitHub repo mirrors can be fetched via the API. Checking up front
// 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) {
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 {
return await fetchValidManifest('the raw URL', getManifestFromURL);
@ -98895,21 +98943,25 @@ function install_python_getManifestFromRepo() {
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}`);
// api.github.com is a GitHub-owned URL. Prefer MIRROR_TOKEN (the user provided token), fall back to TOKEN.
// 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
? undefined
: `token ${token}`
: `token ${mirrorToken}`;
const auth = mirrorToken
? `token ${mirrorToken}`
: token
? `token ${token}`
: undefined;
return getManifestFromRepo(coords.owner, coords.repo, auth, coords.branch);
}
async function getManifestFromURL() {
core_debug('Falling back to fetching the manifest using raw URL.');
const manifestUrl = getManifestUrl();
const http = new lib_HttpClient('tool-cache');
const response = await http.getJson(manifestUrl);
const auth = authForUrl(manifestUrl);
const response = await http.getJson(manifestUrl, auth ? { authorization: auth } : undefined);
if (!response.result) {
throw new Error(`Unable to get manifest from ${manifestUrl}`);
}
@ -103495,6 +103547,16 @@ function isPyPyVersion(versionSpec) {
function isGraalPyVersion(versionSpec) {
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) {
const cacheDependencyPath = getInput('cache-dependency-path') || undefined;
const cacheDistributor = getCacheDistributor(cache, pythonVersion, cacheDependencyPath);
@ -103556,11 +103618,13 @@ async function run() {
startGroup('Installed versions');
for (const version of versions) {
if (isPyPyVersion(version)) {
warnIfMirrorUnsupported(version);
const installed = await findPyPyVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases);
pythonVersion = `${installed.resolvedPyPyVersion}-${installed.resolvedPythonVersion}`;
info(`Successfully set up PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})`);
}
else if (isGraalPyVersion(version)) {
warnIfMirrorUnsupported(version);
const installed = await findGraalPyVersion(version, arch, updateEnvironment, checkLatest, allowPreReleases);
pythonVersion = `${installed}`;
info(`Successfully set up GraalPy ${installed}`);

View File

@ -533,12 +533,12 @@ 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:
Authentication is decided by the host of each request, so neither credential reaches a server you did not nominate:
- `token` is forwarded **only** to `github.com` and hosts under `*.github.com` or `*.githubusercontent.com`. It is never sent to a custom mirror.
- `mirror-token` takes precedence over `token`: if `mirror-token` is set it is used for every authenticated request (manifest fetch and tarball downloads).
- If `mirror-token` is empty, `token` is used when the target URL is GitHub-owned.
- If neither applies, requests are anonymous.
- 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):
@ -559,6 +559,11 @@ Point at an internal mirror with its own credential:
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
`setup-python` is able to configure **PyPy** from two sources:

View File

@ -26,48 +26,99 @@ 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 raw = (core.getInput('mirror') || DEFAULT_MIRROR)
.trim()
.replace(/\/+$/, '');
try {
new URL(raw);
} catch {
throw new Error(`Invalid 'mirror' URL: "${raw}"`);
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);
}
return raw;
if ('error' in resolved) throw resolved.error;
return resolved.url;
}
export function getManifestUrl(): string {
return `${getMirror()}/versions-manifest.json`;
}
function resolveRepoCoords(): {
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 m = REPO_COORDS_RE.exec(getMirror());
return m ? {owner: m[1], repo: m[2], branch: m[3]} : 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 {
const mirrorToken = getMirrorToken();
if (mirrorToken) return `token ${mirrorToken}`;
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 &&
(host === 'github.com' ||
host.endsWith('.github.com') ||
host.endsWith('.githubusercontent.com'))
)
return `token ${token}`;
if (token && isGitHubHost(host)) return `token ${token}`;
return undefined;
}
@ -260,15 +311,24 @@ async function fetchValidManifest(
}
export async function getManifest(): Promise<tc.IToolRelease[]> {
try {
return await fetchValidManifest('the GitHub API', 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.');
// Only GitHub repo mirrors can be fetched via the API. Checking up front
// avoids burning MANIFEST_FETCH_MAX_ATTEMPTS with backoff on a throw that
// could never succeed.
if (resolveRepoCoords()) {
try {
return await fetchValidManifest('the GitHub API', 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.');
}
}
} else {
core.debug(
`Mirror "${getMirror()}" is not a GitHub repo URL; fetching the manifest by URL.`
);
}
try {
@ -293,14 +353,17 @@ export function getManifestFromRepo(): Promise<tc.IToolRelease[]> {
core.debug(
`Getting manifest from ${coords.owner}/${coords.repo}@${coords.branch}`
);
// api.github.com is a GitHub-owned URL. Prefer MIRROR_TOKEN (the user provided token), fall back to TOKEN.
// 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
? undefined
: `token ${token}`
: `token ${mirrorToken}`;
const auth = mirrorToken
? `token ${mirrorToken}`
: token
? `token ${token}`
: undefined;
return tc.getManifestFromRepo(coords.owner, coords.repo, auth, coords.branch);
}
@ -309,7 +372,11 @@ export async function getManifestFromURL(): Promise<tc.IToolRelease[]> {
const manifestUrl = getManifestUrl();
const http: httpm.HttpClient = new httpm.HttpClient('tool-cache');
const response = await http.getJson<tc.IToolRelease[]>(manifestUrl);
const auth = authForUrl(manifestUrl);
const response = await http.getJson<tc.IToolRelease[]>(
manifestUrl,
auth ? {authorization: auth} : undefined
);
if (!response.result) {
throw new Error(`Unable to get manifest from ${manifestUrl}`);
}

View File

@ -23,6 +23,19 @@ function isGraalPyVersion(versionSpec: string) {
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) {
const cacheDependencyPath =
core.getInput('cache-dependency-path') || undefined;
@ -102,6 +115,7 @@ async function run() {
core.startGroup('Installed versions');
for (const version of versions) {
if (isPyPyVersion(version)) {
warnIfMirrorUnsupported(version);
const installed = await finderPyPy.findPyPyVersion(
version,
arch,
@ -114,6 +128,7 @@ async function run() {
`Successfully set up PyPy ${installed.resolvedPyPyVersion} with Python (${installed.resolvedPythonVersion})`
);
} else if (isGraalPyVersion(version)) {
warnIfMirrorUnsupported(version);
const installed = await finderGraalPy.findGraalPyVersion(
version,
arch,