mirror of
https://github.com/actions/setup-python.git
synced 2025-12-23 08:47:09 +00:00
Add support for custom PyPI repository configuration
- Add pypi-url, pypi-username, and pypi-password inputs to action.yml - Implement configurePipRepository() function in utils.ts to create pip.conf/pip.ini - Integrate pip configuration into setup-python.ts workflow - Add comprehensive unit tests for pip configuration functionality - Update README.md with usage examples and documentation - Automatically mask credentials in logs for security Fixes #814
This commit is contained in:
parent
83679a892e
commit
f054be5a92
5 changed files with 222 additions and 2 deletions
27
README.md
27
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).
|
>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.
|
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
|
## Advanced usage
|
||||||
|
|
||||||
- [Using the python-version input](docs/advanced-usage.md#using-the-python-version-input)
|
- [Using the python-version input](docs/advanced-usage.md#using-the-python-version-input)
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ import {
|
||||||
isGhes,
|
isGhes,
|
||||||
IS_WINDOWS,
|
IS_WINDOWS,
|
||||||
getDownloadFileName,
|
getDownloadFileName,
|
||||||
getVersionInputFromToolVersions
|
getVersionInputFromToolVersions,
|
||||||
|
configurePipRepository
|
||||||
} from '../src/utils';
|
} from '../src/utils';
|
||||||
|
|
||||||
jest.mock('@actions/cache');
|
jest.mock('@actions/cache');
|
||||||
|
|
@ -378,3 +379,106 @@ describe('isGhes', () => {
|
||||||
expect(isGhes()).toBeTruthy();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,15 @@ inputs:
|
||||||
description: "Used to specify the version of pip to install with the Python. Supported format: major[.minor][.patch]."
|
description: "Used to specify the version of pip to install with the Python. Supported format: major[.minor][.patch]."
|
||||||
pip-install:
|
pip-install:
|
||||||
description: "Used to specify the packages to install with pip after setting up Python. Can be a requirements file or package names."
|
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:
|
outputs:
|
||||||
python-version:
|
python-version:
|
||||||
description: "The installed Python or PyPy version. Useful when given a version range as input."
|
description: "The installed Python or PyPy version. Useful when given a version range as input."
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import {
|
||||||
logWarning,
|
logWarning,
|
||||||
IS_MAC,
|
IS_MAC,
|
||||||
getVersionInputFromFile,
|
getVersionInputFromFile,
|
||||||
getVersionsInputFromPlainFile
|
getVersionsInputFromPlainFile,
|
||||||
|
configurePipRepository
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import {exec} from '@actions/exec';
|
import {exec} from '@actions/exec';
|
||||||
|
|
||||||
|
|
@ -159,6 +160,12 @@ async function run() {
|
||||||
if (cache && isCacheFeatureAvailable()) {
|
if (cache && isCacheFeatureAvailable()) {
|
||||||
await cacheDependencies(cache, pythonVersion);
|
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');
|
const pipInstall = core.getInput('pip-install');
|
||||||
if (pipInstall) {
|
if (pipInstall) {
|
||||||
await installPipPackages(pipInstall);
|
await installPipPackages(pipInstall);
|
||||||
|
|
|
||||||
73
src/utils.ts
73
src/utils.ts
|
|
@ -422,3 +422,76 @@ export function getDownloadFileName(downloadUrl: string): string | undefined {
|
||||||
? path.join(tempDir, path.basename(downloadUrl))
|
? path.join(tempDir, path.basename(downloadUrl))
|
||||||
: undefined;
|
: 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<void> {
|
||||||
|
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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue