Help us learn about your current experience with the documentation. Take the survey.

教程:使用 GitLab CI/CD 构建和签名 Python 包

本教程教你如何为 Python 包实现安全管道。该管道包含使用 GitLab CI/CD 和 Sigstore Cosign 对 Python 包进行加密签名和验证的阶段。

完成本教程后,你将学习如何:

  • 使用 GitLab CI/CD 构建和签名 Python 包。
  • 使用通用包注册表存储和管理包签名。
  • 作为终端用户验证包签名。

包签名有什么好处?

包签名提供几个关键的安全优势:

  • 真实性:用户可以验证包来自可信来源。
  • 数据完整性:如果在分发过程中包被篡改,将被检测到。
  • 不可否认性:可以加密证明包的来源。
  • 供应链安全:包签名可以防止供应链攻击和受损的仓库。

开始之前

要完成本教程,你需要:

  • 一个 GitLab 账户和测试项目。
  • 对 Python 打包、GitLab CI/CD 和包注册表概念的基本熟悉。

步骤

以下是你要做的概述:

  1. 设置 Python 项目。
  2. 添加基础配置。
  3. 配置构建阶段。
  4. 配置签名阶段。
  5. 配置验证阶段。
  6. 配置发布阶段。
  7. 配置发布签名阶段。
  8. 配置消费者验证阶段。
  9. 作为用户验证包。

设置 Python 项目

首先,创建一个测试项目。在项目根目录中添加一个 pyproject.toml 文件:

[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "<my_package>"  # 将被 CI/CD 管道动态替换
version = "<1.0.0>"    # 将被 CI/CD 管道动态替换
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>"  # 将被替换为实际项目 URL

确保将 Your Name[email protected] 替换为你自己的个人信息。

当你完成以下步骤中的 CI/CD 管道构建时,管道会自动:

  • my_package 替换为项目名称的规范化版本。
  • version 更改为与管道版本匹配。
  • Homepage URL 更改为你的 GitLab 项目 URL。

添加基础配置

在你的项目根目录中,添加一个 .gitlab-ci.yml 文件。添加以下配置:

variables:
  # 所有作业的基础 Python 版本
  PYTHON_VERSION: '3.10'
  # 包名称和版本
  PACKAGE_NAME: ${CI_PROJECT_NAME}
  PACKAGE_VERSION: "1.0.0"  # 使用语义化版本
  # Sigstore 服务 URL
  FULCIO_URL: 'https://fulcio.sigstore.dev'
  REKOR_URL: 'https://rekor.sigstore.dev'
  # Sigstore 验证的标识
  CERTIFICATE_IDENTITY: 'https://gitlab.com/${CI_PROJECT_PATH}//.gitlab-ci.yml@refs/heads/${CI_DEFAULT_BRANCH}'
  CERTIFICATE_OIDC_ISSUER: 'https://gitlab.com'
  # 用于更快构建的 pip 缓存目录
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
  # 自动接受 Cosign 的提示
  COSIGN_YES: "true"
  # 通用包注册表的基础 URL
  GENERIC_PACKAGE_BASE_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/${PACKAGE_NAME}/${PACKAGE_VERSION}"

default:
  before_script:
    # 在任何作业开始时规范化包名称一次
    - export NORMALIZED_NAME=$(echo "${CI_PROJECT_NAME}" | tr '-' '_')

# 基于 Python 的作业模板
.python-job:
  image: python:${PYTHON_VERSION}
  before_script:
    # 首先规范化包名称
    - export NORMALIZED_NAME=$(echo "${CI_PROJECT_NAME}" | tr '-' '_')
    # 然后安装 Python 依赖
    - pip install --upgrade pip
    - pip install build twine setuptools wheel
  cache:
    paths:
      - ${PIP_CACHE_DIR}

# Python + Cosign 作业模板
.python+cosign-job:
  extends: .python-job
  before_script:
    # 首先规范化包名称
    - export NORMALIZED_NAME=$(echo "${CI_PROJECT_NAME}" | tr '-' '_')
    # 然后安装依赖
    - 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

这个基础配置:

  • 指示管道使用 Python 3.10 作为基础镜像以确保一致性
  • 设置两个可重用模板:.python-job 用于基本 Python 操作,.python+cosign-job 用于签名操作
  • 实现 pip 缓存以加速构建
  • 通过将连字符转换为下划线来规范化包名称,以实现 Python 兼容性
  • 在管道级别定义所有关键变量以便于管理

配置构建阶段

构建阶段构建 Python 分发包。

在你的 .gitlab-ci.yml 文件中,添加以下配置:

build:
  extends: .python-job
  stage: build
  script:
    # 使用实际内容初始化 git 仓库
    - 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"

    # 更新 pyproject.toml 中的包名称、版本和主页 URL
    - 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

    # 调试:显示更新后的文件
    - echo "Updated pyproject.toml contents:"
    - cat pyproject.toml

    # 构建包
    - python -m build
  artifacts:
    paths:
      - dist/
      - pyproject.toml

构建阶段配置:

  • 为构建上下文初始化 Git 仓库
  • 动态更新 pyproject.toml 中的包元数据
  • 同时添加 wheel (.whl) 和源码分发包 (.tar.gz)
  • 为后续阶段保留构建产物
  • 提供故障排除的调试输出

配置签名阶段

签名阶段使用 Sigstore Cosign 对包进行签名。

在你的 .gitlab-ci.yml 文件中,添加以下配置:

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"

          # 调试:验证文件是否已创建
          echo "Checking generated signature and certificate:"
          ls -l "dist/${filename}.sig" "dist/${filename}.crt"
        fi
      done
  artifacts:
    paths:
      - dist/

签名阶段配置:

  • 使用 Sigstore 的无密钥签名以增强安全性
  • 对 wheel 和源码分发包进行签名
  • 创建单独的签名 (.sig) 和证书 (.crt) 文件
  • 使用 OIDC 集成进行身份验证
  • 包含签名生成的详细日志记录

配置验证阶段

验证阶段在本地验证签名。

在你的 .gitlab-ci.yml 文件中,添加以下配置:

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

验证阶段配置:

  • 在签名后立即验证签名
  • 检查 wheel 和源码分发包
  • 验证证书标识和 OIDC 签发者
  • 如果任何验证失败则快速失败
  • 提供详细的验证日志

配置发布阶段

发布阶段将包上传到 GitLab PyPI 包注册表。

在你的 .gitlab-ci.yml 文件中,添加以下配置:

publish:
  extends: .python-job
  stage: publish
  script:
    - |
      # 为 GitLab 包注册表配置 PyPI 设置
      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

      # 使用 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

发布阶段配置:

  • 配置 PyPI 注册表身份验证
  • 使用 GitLab 内置包注册表
  • 发布 wheel 和源码分发包
  • 使用作业令牌进行安全身份验证
  • 创建可重用的 .pypirc 配置

配置发布签名阶段

发布签名阶段将签名存储在 GitLab 通用包注册表中。

在你的 .gitlab-ci.yml 文件中,添加以下配置:

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"

          # 上传签名和证书
          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

发布签名阶段配置:

  • 将签名存储在通用包注册表中
  • 保持签名到包的映射
  • 使用一致的命名约定作为产物
  • 包含签名的大小验证
  • 提供详细的上传日志

配置消费者验证阶段

消费者验证阶段模拟终端用户包验证。

在你的 .gitlab-ci.yml 文件中,添加以下配置:

consumer_verification:
  extends: .python+cosign-job
  stage: consumer_verification
  script:
    - |
      # 为 setuptools_scm 初始化 git 仓库
      git init
      git config --global init.defaultBranch main

      # 创建用于下载包的目录
      mkdir -p pkg signatures

      # 下载特定的 wheel 版本
      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

      # 下载特定的源码分发包版本
      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"

          # 下载签名
          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"

          # 验证签名
          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

消费者验证阶段配置:

  • 模拟真实世界的包安装
  • 下载并验证两种包格式
  • 使用精确的版本匹配以确保一致性
  • 实现全面的错误处理
  • 测试完整的验证工作流

作为用户验证包

作为终端用户,你可以通过以下步骤验证包签名:

  1. 安装 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 需要特殊权限进行全局安装。使用 sudo 来绕过权限问题。

  2. 下载包及其签名:

    # 你可以在 GitLab 项目主页的项目名称下找到 PROJECT_ID
    
    # 下载特定版本的包
    pip download your-package-name==1.0.0 --no-deps
    
    # FILENAME 将是 pip download 命令的输出
    # 例如:your-package-name-1.0.0.tar.gz 或 your-package-name-1.0.0-py3-none-any.whl
    
    # 从 GitLab 的通用包注册表下载签名
    # 用你的项目详情替换这些值:
    # GITLAB_URL: 你的 GitLab 实例 URL(例如,https://gitlab.com)
    # PROJECT_ID: 你的项目 ID 号
    # PACKAGE_NAME: 你的包名称
    # VERSION: 包版本(例如,1.0.0)
    # FILENAME: 你下载的包的确切文件名
    
    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"
  3. 验证签名:

    # 用项目管道中的值替换 CERTIFICATE_IDENTITY 和 CERTIFICATE_OIDC_ISSUER
    export CERTIFICATE_IDENTITY="https://gitlab.com/your-group/your-project//.gitlab-ci.yml@refs/heads/main"
    export CERTIFICATE_OIDC_ISSUER="https://gitlab.com"
    
    # 验证 wheel 包
    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}"
    
    # 验证源码分发包
    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}"

作为终端用户验证包时:

  • 确保包下载与你想要验证的版本完全匹配。
  • 分别验证每种包类型(wheel 和源码分发包)。
  • 确保证书标识与用于签名的包完全匹配。
  • 检查所有 URL 组件是否正确设置。例如,GITLAB_URLPROJECT_ID
  • 检查包文件名是否与上传到注册表的完全匹配。
  • 使用 COSIGN_EXPERIMENTAL=1 功能标志进行无密钥验证。此标志是必需的。
  • 了解验证失败可能表示篡改或不正确的证书和签名对。
  • 记住项目管道中的证书标识和签发者值。

故障排除

完成本教程时,你可能会遇到以下错误:

错误:404 Not Found

如果你遇到 404 Not Found 错误页面:

  • 仔细检查所有 URL 组件。
  • 验证注册表中是否存在该包版本。
  • 确保文件名完全匹配,包括版本和平台标签。

验证失败

如果签名验证失败,请确保:

  • CERTIFICATE_IDENTITY 与签名管道匹配。
  • CERTIFICATE_OIDC_ISSUER 正确。
  • 签名和证书对与包正确对应。

权限被拒绝

如果你遇到权限问题:

  • 检查你是否有权访问包注册表。
  • 如果注册表是私有的,验证身份验证。
  • 安装 Cosign 时使用正确的文件权限。

身份验证问题

如果你遇到身份验证问题:

  • 检查 CI_JOB_TOKEN 权限。
  • 验证注册表身份验证配置。
  • 验证项目的访问设置。

验证包配置和管道设置

检查包配置。确保:

  • 包名称使用下划线 (_),而不是连字符 (-)。
  • 版本字符串使用有效的 PEP 440
  • pyproject.toml 文件格式正确。

检查管道设置。确保:

  • OIDC 配置正确。
  • 作业依赖关系正确设置。
  • 所需权限已就位。