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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,35 @@ 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 or overwrite 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.

> **Warning:** If a `pip.conf` or `pip.ini` file already exists at that location, its contents will be overwritten by this action for the duration of the job. Existing settings are not merged or preserved.
**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)
Expand Down
108 changes: 107 additions & 1 deletion __tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
isGhes,
IS_WINDOWS,
getDownloadFileName,
getVersionInputFromToolVersions
getVersionInputFromToolVersions,
configurePipRepository
} from '../src/utils';

jest.mock('@actions/cache');
Expand Down Expand Up @@ -378,3 +379,108 @@ 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]');
const encodedUsername = encodeURIComponent(username);
const encodedPassword = encodeURIComponent(password);
expect(content).toContain(`index-url = https://${encodedUsername}:${encodedPassword}@`);
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();
});
});
Comment on lines +383 to +486
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no test coverage for the error handling path when URL parsing fails or when file writing fails. Consider adding tests that verify the behavior when an invalid URL is provided or when the file system operations fail.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

9 changes: 9 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
15 changes: 14 additions & 1 deletion src/setup-python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
logWarning,
IS_MAC,
getVersionInputFromFile,
getVersionsInputFromPlainFile
getVersionsInputFromPlainFile,
configurePipRepository
} from './utils';
import {exec} from '@actions/exec';

Expand Down Expand Up @@ -159,6 +160,18 @@ 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');
if (pypiUsername && pypiUsername.trim()) {
core.setSecret(pypiUsername);
}
if (pypiPassword && pypiPassword.trim()) {
core.setSecret(pypiPassword);
}
await configurePipRepository(pypiUrl, pypiUsername, pypiPassword);
}
const pipInstall = core.getInput('pip-install');
if (pipInstall) {
await installPipPackages(pipInstall);
Expand Down
76 changes: 76 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,3 +422,79 @@ 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<void> {
Comment on lines +433 to +437
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function is declared as async but doesn't use any await operations. The async keyword is unnecessary and adds overhead. Either remove the async keyword and change the return type from Promise to void, or if file operations should be asynchronous for consistency, use the async versions of fs methods like fs.promises.writeFile.

Copilot uses AI. Check for mistakes.
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);
Comment on lines +445 to +450
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If both HOME and USERPROFILE environment variables are undefined or empty, homeDir will be an empty string, resulting in config paths like 'pip/pip.ini' or '.pip/pip.conf' in the current working directory instead of the user's home directory. This could cause pip configuration issues. Consider validating that homeDir is not empty and throwing an error or logging a warning if it cannot be determined.

Copilot uses AI. Check for mistakes.

// 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'});
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config file is written with destructive writeFileSync, which will overwrite any existing pip configuration. If users have existing pip.conf/pip.ini files with other settings (like trusted-hosts, timeout values, etc.), they will be lost. Consider reading and merging with existing configuration, or at minimum documenting this behavior and warning users.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

core.info(`Successfully created pip config file at: ${configPath}`);

// Mask credentials in logs if they were used
if (username) {
core.setSecret(username);
}
if (password) {
core.setSecret(password);
}
} catch (error) {
core.setFailed(
`Failed to create pip config file at ${configPath}: ${error}`
Comment on lines +496 to +497
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error object is being directly interpolated into a string, which may not provide useful information. Error objects should be accessed via their message property or converted to string explicitly for better error reporting.

Suggested change
core.setFailed(
`Failed to create pip config file at ${configPath}: ${error}`
const errorMessage =
error instanceof Error ? error.message : String(error);
core.setFailed(
`Failed to create pip config file at ${configPath}: ${errorMessage}`

Copilot uses AI. Check for mistakes.
);
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When core.setFailed() is called, it doesn't throw an error or stop execution. The function continues to execute and returns normally. This means that if the config file write fails, the workflow step will be marked as failed but subsequent operations (like pip install) may still attempt to run. Consider throwing an error after setFailed() or restructuring the error handling to ensure the promise rejects.

Suggested change
);
);
throw error instanceof Error
? error
: new Error(
`Failed to create pip config file at ${configPath}: ${error}`
);

Copilot uses AI. Check for mistakes.
}
}