doc/user/packages/package_registry/pypi_cosign_tutorial.md
This tutorial shows you how to implement a secure pipeline for Python packages. The pipeline includes stages that cryptographically sign and verify Python packages using GitLab CI/CD and Sigstore Cosign.
By the end, you'll learn how to:
Package signing provides several crucial security benefits:
To complete this tutorial, you need:
Here's an overview of what you're going to do:
First, create a test project. Add a pyproject.toml file in your project root:
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "<my_package>" # Will be dynamically replaced by CI/CD pipeline
version = "<1.0.0>" # Will be dynamically replaced by CI/CD pipeline
description = "<Your package description>"
readme = "README.md"
requires-python = ">=3.7"
authors = [
{name = "<Your Name>", email = "<[email protected]>"},
]
[project.urls]
"Homepage" = "<https://gitlab.com/my_package>" # Will be replaced with actual project URL
Make sure you replace Your Name and [email protected] with your own personal details.
When you finish building your CI/CD pipeline in the following steps, the pipeline automatically:
my_package with a normalized version of your project name.version to match the pipeline version.Homepage URL to match your GitLab project URL.In your project root, add a .gitlab-ci.yml file. Add the following configuration:
variables:
# Base Python version for all jobs
PYTHON_VERSION: '3.10'
# Package names and versions
PACKAGE_NAME: ${CI_PROJECT_NAME}
PACKAGE_VERSION: "1.0.0" # Use semantic versioning
# Sigstore service URLs
FULCIO_URL: 'https://fulcio.sigstore.dev'
REKOR_URL: 'https://rekor.sigstore.dev'
# Identity for Sigstore verification
CERTIFICATE_IDENTITY: 'https://gitlab.com/${CI_PROJECT_PATH}//.gitlab-ci.yml@refs/heads/${CI_DEFAULT_BRANCH}'
CERTIFICATE_OIDC_ISSUER: 'https://gitlab.com'
# Pip cache directory for faster builds
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
# Auto-accept prompts from Cosign
COSIGN_YES: "true"
# Base URL for generic package registry
GENERIC_PACKAGE_BASE_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${PACKAGE_NAME}/${PACKAGE_VERSION}"
default:
before_script:
# Normalize package name once at the start of any job
- export NORMALIZED_NAME=$(echo "${CI_PROJECT_NAME}" | tr '-' '_')
# Template for Python-based jobs
.python-job:
image: python:${PYTHON_VERSION}
before_script:
# First normalize package name
- export NORMALIZED_NAME=$(echo "${CI_PROJECT_NAME}" | tr '-' '_')
# Then install Python dependencies
- pip install --upgrade pip
- pip install build twine setuptools wheel
cache:
paths:
- ${PIP_CACHE_DIR}
# Template for Python + Cosign jobs
.python+cosign-job:
extends: .python-job
before_script:
# First normalize package name
- export NORMALIZED_NAME=$(echo "${CI_PROJECT_NAME}" | tr '-' '_')
# Then install dependencies
- apt-get update && apt-get install -y curl wget
- wget -O cosign https://github.com/sigstore/cosign/releases/download/v2.2.3/cosign-linux-amd64
- chmod +x cosign && mv cosign /usr/local/bin/
- export COSIGN_EXPERIMENTAL=1
- pip install --upgrade pip
- pip install build twine setuptools wheel
stages:
- build
- sign
- verify
- publish
- publish_signatures
- consumer_verification
This base configuration:
3.10 as the base image for consistency.python-job for basic Python operations and .python+cosign-job for signing operationsThe build stage builds Python distribution packages.
In your .gitlab-ci.yml file, add the following configuration:
build:
extends: .python-job
stage: build
script:
# Initialize git repo with actual content
- git init
- git config --global init.defaultBranch main
- git config --global user.email "[email protected]"
- git config --global user.name "CI"
- git add .
- git commit -m "Initial commit"
# Update package name, version, and homepage URL in pyproject.toml
- sed -i "s/name = \".*\"/name = \"${NORMALIZED_NAME}\"/" pyproject.toml
- sed -i "s/version = \".*\"/version = \"${PACKAGE_VERSION}\"/" pyproject.toml
- sed -i "s|\"Homepage\" = \".*\"|\"Homepage\" = \"https://gitlab.com/${CI_PROJECT_PATH}\"|" pyproject.toml
# Debug: show updated file
- echo "Updated pyproject.toml contents:"
- cat pyproject.toml
# Build package
- python -m build
artifacts:
paths:
- dist/
- pyproject.toml
The build stage configuration:
pyproject.toml.whl) and source distribution (.tar.gz) packagesThe sign stage signs packages using Sigstore Cosign.
In your .gitlab-ci.yml file, add the following configuration:
sign:
extends: .python+cosign-job
stage: sign
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
script:
- |
for file in dist/*.whl dist/*.tar.gz; do
if [ -f "$file" ]; then
filename=$(basename "$file")
cosign sign-blob --yes \
--fulcio-url=${FULCIO_URL} \
--rekor-url=${REKOR_URL} \
--oidc-issuer $CI_SERVER_URL \
--identity-token $SIGSTORE_ID_TOKEN \
--output-signature "dist/${filename}.sig" \
--output-certificate "dist/${filename}.crt" \
"$file"
# Debug: Verify files were created
echo "Checking generated signature and certificate:"
ls -l "dist/${filename}.sig" "dist/${filename}.crt"
fi
done
artifacts:
paths:
- dist/
The sign stage configuration:
.sig) and certificate (.crt) filesThe verify stage validates signatures locally.
In your .gitlab-ci.yml file, add the following configuration:
verify:
extends: .python+cosign-job
stage: verify
script:
- |
failed=0
for file in dist/*.whl dist/*.tar.gz; do
if [ -f "$file" ]; then
filename=$(basename "$file")
echo "Verifying file: $file"
echo "Using signature: dist/${filename}.sig"
echo "Using certificate: dist/${filename}.crt"
if ! cosign verify-blob \
--signature "dist/${filename}.sig" \
--certificate "dist/${filename}.crt" \
--certificate-identity "${CERTIFICATE_IDENTITY}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
"$file"; then
echo "Verification failed for $filename"
failed=1
fi
fi
done
if [ $failed -eq 1 ]; then
exit 1
fi
The verify stage configuration:
The publish stage uploads packages to the GitLab PyPI package registry.
In your .gitlab-ci.yml file, add the following configuration:
publish:
extends: .python-job
stage: publish
script:
- |
# Configure PyPI settings for GitLab package registry
cat << EOF > ~/.pypirc
[distutils]
index-servers = gitlab
[gitlab]
repository = ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi
username = gitlab-ci-token
password = ${CI_JOB_TOKEN}
EOF
# Upload packages using twine
TWINE_PASSWORD=${CI_JOB_TOKEN} TWINE_USERNAME=gitlab-ci-token \
twine upload --repository-url ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi \
dist/*.whl dist/*.tar.gz
The publish stage configuration:
.pypirc configurationThe publish signatures stage stores signatures in the GitLab generic package registry.
In your .gitlab-ci.yml file, add the following configuration:
publish_signatures:
extends: .python+cosign-job
stage: publish_signatures
script:
- |
for file in dist/*.whl dist/*.tar.gz; do
if [ -f "$file" ]; then
filename=$(basename "$file")
ls -l "dist/${filename}.sig" "dist/${filename}.crt"
echo "Publishing signatures for $filename"
echo "Publishing to: ${GENERIC_PACKAGE_BASE_URL}/${filename}.sig"
# Upload signature and certificate
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--fail \
--upload-file "dist/${filename}.sig" \
"${GENERIC_PACKAGE_BASE_URL}/${filename}.sig"
curl --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--fail \
--upload-file "dist/${filename}.crt" \
"${GENERIC_PACKAGE_BASE_URL}/${filename}.crt"
fi
done
The publish signatures stage configuration:
The consumer verification stage simulates end-user package verification.
In your .gitlab-ci.yml file, add the following configuration:
consumer_verification:
extends: .python+cosign-job
stage: consumer_verification
script:
- |
# Initialize git repo for setuptools_scm
git init
git config --global init.defaultBranch main
# Create directory for downloading packages
mkdir -p pkg signatures
# Download the specific wheel version
pip download --index-url "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/pypi/simple" \
"${NORMALIZED_NAME}==${PACKAGE_VERSION}" --no-deps -d ./pkg --verbose
# Download the specific source distribution version
pip download --no-binary :all: \
--index-url "https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/pypi/simple" \
"${NORMALIZED_NAME}==${PACKAGE_VERSION}" --no-deps -d ./pkg --verbose
failed=0
for file in pkg/*.whl pkg/*.tar.gz; do
if [ -f "$file" ]; then
filename=$(basename "$file")
sig_url="${GENERIC_PACKAGE_BASE_URL}/${filename}.sig"
cert_url="${GENERIC_PACKAGE_BASE_URL}/${filename}.crt"
echo "Downloading signatures for $filename"
echo "Signature URL: $sig_url"
echo "Certificate URL: $cert_url"
# Download signatures
curl --fail --silent --show-error \
--header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--output "signatures/${filename}.sig" \
"$sig_url"
curl --fail --silent --show-error \
--header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
--output "signatures/${filename}.crt" \
"$cert_url"
# Verify signature
if ! cosign verify-blob \
--signature "signatures/${filename}.sig" \
--certificate "signatures/${filename}.crt" \
--certificate-identity "${CERTIFICATE_IDENTITY}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
"$file"; then
echo "Signature verification failed"
failed=1
fi
fi
done
if [ $failed -eq 1 ]; then
echo "Verification failed for one or more packages"
exit 1
fi
The consumer verification stage configuration:
As an end user, you can verify package signatures with the following steps:
Install Cosign:
wget -O cosign https://github.com/sigstore/cosign/releases/download/v2.2.3/cosign-linux-amd64
chmod +x cosign && sudo mv cosign /usr/local/bin/
Cosign requires special permissions for global installations. Use sudo to bypass permissions issues.
Download the package and its signatures:
# You can find your PROJECT_ID in your GitLab project's home page under the project name
# Download the specific version of the package
pip download your-package-name==1.0.0 --no-deps
# The FILENAME will be the output from the pip download command
# For example: your-package-name-1.0.0.tar.gz or your-package-name-1.0.0-py3-none-any.whl
# Download signatures from GitLab's generic package registry
# Replace these values with your project's details:
# GITLAB_URL: Your GitLab instance URL (for example, https://gitlab.com)
# PROJECT_ID: Your project's ID number
# PACKAGE_NAME: Your package name
# VERSION: Package version (for example, 1.0.0)
# FILENAME: The exact filename of your downloaded package
curl --output "${FILENAME}.sig" \
"${GITLAB_URL}/api/v4/projects/${PROJECT_ID}/packages/generic/${PACKAGE_NAME}/${VERSION}/${FILENAME}.sig"
curl --output "${FILENAME}.crt" \
"${GITLAB_URL}/api/v4/projects/${PROJECT_ID}/packages/generic/${PACKAGE_NAME}/${VERSION}/${FILENAME}.crt"
Verify the signatures:
# Replace CERTIFICATE_IDENTITY and CERTIFICATE_OIDC_ISSUER with the values from the project's pipeline
export CERTIFICATE_IDENTITY="https://gitlab.com/your-group/your-project//.gitlab-ci.yml@refs/heads/main"
export CERTIFICATE_OIDC_ISSUER="https://gitlab.com"
# Verify wheel package
FILENAME="your-package-name-1.0.0-py3-none-any.whl"
COSIGN_EXPERIMENTAL=1 cosign verify-blob \
--signature "${FILENAME}.sig" \
--certificate "${FILENAME}.crt" \
--certificate-identity "${CERTIFICATE_IDENTITY}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
"${FILENAME}"
# Verify source distribution
FILENAME="your-package-name-1.0.0.tar.gz"
COSIGN_EXPERIMENTAL=1 cosign verify-blob \
--signature "${FILENAME}.sig" \
--certificate "${FILENAME}.crt" \
--certificate-identity "${CERTIFICATE_IDENTITY}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
"${FILENAME}"
When verifying packages as an end user:
GITLAB_URL or PROJECT_ID.COSIGN_EXPERIMENTAL=1 feature flag for keyless verification. This flag is required.When completing this tutorial, you might encounter the following errors:
404 Not FoundIf you encounter a 404 Not Found error page:
If signature verification fails, make sure:
CERTIFICATE_IDENTITY matches the signing pipeline.CERTIFICATE_OIDC_ISSUER is correct.If you encounter permissions issues:
If you encounter authentication issues:
CI_JOB_TOKEN permissions.Check the package configuration. Make sure:
_), not hyphens (-).pyproject.toml file is properly formatted.Check the pipeline settings. Make sure: