diff --git a/README.md b/README.md index a6fb5bc5..245711e8 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,34 @@ steps: >The `setup-python` action does not handle authentication for pip when installing packages from private repositories. For help, refer [pip’s VCS support documentation](https://pip.pypa.io/en/stable/topics/vcs-support/) or visit the [pip repository](https://github.com/pypa/pip). See examples of using `cache` and `cache-dependency-path` for `pipenv` and `poetry` in the section: [Caching packages](docs/advanced-usage.md#caching-packages) of the [Advanced usage](docs/advanced-usage.md) guide. +## Configuring a custom PyPI repository +The action supports configuring pip to use a custom PyPI repository (e.g., a private Nexus, Artifactory, or other PyPI-compatible repository). This is useful in enterprise environments where the public PyPI may be blocked by a firewall, or where you need to use security-scanned packages from an internal repository. + +**Configure custom PyPI repository:** + +```yaml +steps: +- uses: actions/checkout@v5 +- uses: actions/setup-python@v6 + with: + python-version: '3.13' + pypi-url: ${{ secrets.PYPI_REPO_URL }} + pypi-username: ${{ secrets.PYPI_USER }} + pypi-password: ${{ secrets.PYPI_PASSWORD }} +- run: pip install -r requirements.txt +``` + +The action will create a `pip.conf` (Linux/macOS) or `pip.ini` (Windows) file in the appropriate location with the configured repository URL and credentials. All subsequent pip commands will use the configured repository. + +**Input parameters:** +- `pypi-url`: The URL of your custom PyPI repository (e.g., `https://nexus.example.com/repository/pypi/simple`) +- `pypi-username` (optional): Username for authentication with the custom repository +- `pypi-password` (optional): Password or token for authentication with the custom repository + +>**Note:** Both `pypi-username` and `pypi-password` must be provided together for authentication. If only one is provided, the action will configure pip without credentials. + +>**Security Note:** Always use GitHub secrets to store sensitive information like usernames and passwords. Never hardcode credentials in your workflow files. ## Advanced usage - [Using the python-version input](docs/advanced-usage.md#using-the-python-version-input) diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index 2cbfa813..1eafad17 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -17,7 +17,8 @@ import { isGhes, IS_WINDOWS, getDownloadFileName, - getVersionInputFromToolVersions + getVersionInputFromToolVersions, + configurePipRepository } from '../src/utils'; jest.mock('@actions/cache'); @@ -378,3 +379,106 @@ describe('isGhes', () => { expect(isGhes()).toBeTruthy(); }); }); + +describe('configurePipRepository', () => { + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const testHome = path.join(tempDir, 'home'); + + beforeEach(() => { + // Setup test home directory + process.env.HOME = testHome; + process.env.USERPROFILE = testHome; + if (fs.existsSync(testHome)) { + fs.rmSync(testHome, {recursive: true, force: true}); + } + fs.mkdirSync(testHome, {recursive: true}); + }); + + afterEach(() => { + // Cleanup + if (fs.existsSync(testHome)) { + fs.rmSync(testHome, {recursive: true, force: true}); + } + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + }); + + it('creates pip config file with URL only', async () => { + const pypiUrl = 'https://nexus.example.com/repository/pypi/simple'; + await configurePipRepository(pypiUrl); + + const configDir = IS_WINDOWS + ? path.join(testHome, 'pip') + : path.join(testHome, '.pip'); + const configFile = IS_WINDOWS ? 'pip.ini' : 'pip.conf'; + const configPath = path.join(configDir, configFile); + + expect(fs.existsSync(configPath)).toBeTruthy(); + const content = fs.readFileSync(configPath, 'utf8'); + expect(content).toContain('[global]'); + expect(content).toContain(`index-url = ${pypiUrl}`); + }); + + it('creates pip config file with credentials', async () => { + const pypiUrl = 'https://nexus.example.com/repository/pypi/simple'; + const username = 'testuser'; + const password = 'testpass'; + await configurePipRepository(pypiUrl, username, password); + + const configDir = IS_WINDOWS + ? path.join(testHome, 'pip') + : path.join(testHome, '.pip'); + const configFile = IS_WINDOWS ? 'pip.ini' : 'pip.conf'; + const configPath = path.join(configDir, configFile); + + expect(fs.existsSync(configPath)).toBeTruthy(); + const content = fs.readFileSync(configPath, 'utf8'); + expect(content).toContain('[global]'); + expect(content).toContain('index-url = https://testuser:testpass@'); + expect(content).toContain('nexus.example.com/repository/pypi/simple'); + }); + + it('does nothing when pypiUrl is not provided', async () => { + await configurePipRepository(''); + + const configDir = IS_WINDOWS + ? path.join(testHome, 'pip') + : path.join(testHome, '.pip'); + + expect(fs.existsSync(configDir)).toBeFalsy(); + }); + + it('warns when only username is provided', async () => { + const warningMock = jest.spyOn(core, 'warning'); + const pypiUrl = 'https://nexus.example.com/repository/pypi/simple'; + const username = 'testuser'; + await configurePipRepository(pypiUrl, username); + + expect(warningMock).toHaveBeenCalledWith( + 'Both pypi-username and pypi-password must be provided for authentication. Configuring without credentials.' + ); + }); + + it('warns when only password is provided', async () => { + const warningMock = jest.spyOn(core, 'warning'); + const pypiUrl = 'https://nexus.example.com/repository/pypi/simple'; + const password = 'testpass'; + await configurePipRepository(pypiUrl, undefined, password); + + expect(warningMock).toHaveBeenCalledWith( + 'Both pypi-username and pypi-password must be provided for authentication. Configuring without credentials.' + ); + }); + + it('creates config directory if it does not exist', async () => { + const pypiUrl = 'https://nexus.example.com/repository/pypi/simple'; + const configDir = IS_WINDOWS + ? path.join(testHome, 'pip') + : path.join(testHome, '.pip'); + + expect(fs.existsSync(configDir)).toBeFalsy(); + await configurePipRepository(pypiUrl); + expect(fs.existsSync(configDir)).toBeTruthy(); + }); +}); diff --git a/action.yml b/action.yml index 7a9a7b63..a5683ad7 100644 --- a/action.yml +++ b/action.yml @@ -33,6 +33,15 @@ inputs: description: "Used to specify the version of pip to install with the Python. Supported format: major[.minor][.patch]." pip-install: description: "Used to specify the packages to install with pip after setting up Python. Can be a requirements file or package names." + pypi-url: + description: "Used to specify a custom PyPI repository URL. When set, pip will be configured to use this repository." + required: false + pypi-username: + description: "Username for authentication with the custom PyPI repository. Used with 'pypi-url'." + required: false + pypi-password: + description: "Password or token for authentication with the custom PyPI repository. Used with 'pypi-url'." + required: false outputs: python-version: description: "The installed Python or PyPy version. Useful when given a version range as input." diff --git a/src/setup-python.ts b/src/setup-python.ts index 91a0c176..d004a3af 100644 --- a/src/setup-python.ts +++ b/src/setup-python.ts @@ -11,7 +11,8 @@ import { logWarning, IS_MAC, getVersionInputFromFile, - getVersionsInputFromPlainFile + getVersionsInputFromPlainFile, + configurePipRepository } from './utils'; import {exec} from '@actions/exec'; @@ -159,6 +160,12 @@ async function run() { if (cache && isCacheFeatureAvailable()) { await cacheDependencies(cache, pythonVersion); } + const pypiUrl = core.getInput('pypi-url'); + if (pypiUrl) { + const pypiUsername = core.getInput('pypi-username'); + const pypiPassword = core.getInput('pypi-password'); + await configurePipRepository(pypiUrl, pypiUsername, pypiPassword); + } const pipInstall = core.getInput('pip-install'); if (pipInstall) { await installPipPackages(pipInstall); diff --git a/src/utils.ts b/src/utils.ts index 2ee9666f..579c72a9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -422,3 +422,76 @@ export function getDownloadFileName(downloadUrl: string): string | undefined { ? path.join(tempDir, path.basename(downloadUrl)) : undefined; } + +/** + * Configure pip to use a custom PyPI repository + * Creates a pip.conf (Linux/macOS) or pip.ini (Windows) file with repository and credentials + * @param pypiUrl The custom PyPI repository URL + * @param username The username for authentication (optional) + * @param password The password or token for authentication (optional) + */ +export async function configurePipRepository( + pypiUrl: string, + username?: string, + password?: string +): Promise { + if (!pypiUrl) { + return; + } + + core.info(`Configuring pip to use custom PyPI repository: ${pypiUrl}`); + + // Determine the pip config file location and name based on OS + const homeDir = process.env.HOME || process.env.USERPROFILE || ''; + const configDir = IS_WINDOWS + ? path.join(homeDir, 'pip') + : path.join(homeDir, '.pip'); + const configFile = IS_WINDOWS ? 'pip.ini' : 'pip.conf'; + const configPath = path.join(configDir, configFile); + + // Create the config directory if it doesn't exist + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, {recursive: true}); + } + + // Build the index URL with credentials if provided + let indexUrl = pypiUrl; + if (username && password) { + // Parse the URL to inject credentials + try { + const url = new URL(pypiUrl); + url.username = encodeURIComponent(username); + url.password = encodeURIComponent(password); + indexUrl = url.toString(); + } catch (error) { + core.warning( + `Failed to parse PyPI URL: ${pypiUrl}. Using URL without credentials.` + ); + indexUrl = pypiUrl; + } + } else if (username || password) { + core.warning( + 'Both pypi-username and pypi-password must be provided for authentication. Configuring without credentials.' + ); + } + + // Create the pip config content + const configContent = `[global] +index-url = ${indexUrl} +`; + + // Write the config file + try { + fs.writeFileSync(configPath, configContent, {encoding: 'utf8'}); + core.info(`Successfully created pip config file at: ${configPath}`); + + // Mask the password in logs if credentials were used + if (password) { + core.setSecret(password); + } + } catch (error) { + core.setFailed( + `Failed to create pip config file at ${configPath}: ${error}` + ); + } +}