核心目标:把本地 uv 工作流可靠迁移到 GitHub Actions 和生产容器,正确使用锁文件、缓存、私有索引和短期凭据,建立可审计的交付链路。

前置知识:已掌握 uv 项目、锁文件、构建发布和 workspace。

文档基线:uv 0.11.x;GitHub Actions、Docker 和索引行为依据 2026-07-21 的 uv 官方文档复核。CI 中应定期升级并重新验证固定版本。


7.1 生产交付的四条底线

本地执行成功不代表能够稳定交付。CI 和容器至少满足:

  1. 输入可追踪:代码提交、Python、uv、锁文件和基础镜像都有明确版本;
  2. 构建可重复:CI 不静默改锁文件,容器不复制本机 .venv
  3. 权限最小化:测试 job 没有发布权限,长期 token 不进入镜像和日志;
  4. 缓存可丢弃:删除全部缓存后仍能得到正确结果。

仅加速

仅加速

Git 提交
pyproject.toml + uv.lock

CI 校验
lint / type / test

构建产物
wheel / sdist / image

隔离验证
SBOM / 扫描 / 冒烟

受保护发布
OIDC / 审批

uv 缓存

缓存只是一条虚线。如果移除缓存后构建失败,问题在声明或环境,而不是“缓存配置不够好”。


7.2 GitHub Actions:最小可靠工作流

.github/workflows/ci.yml

name: ci

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    name: Python ${{ matrix.python-version }} / ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        python-version: ["3.12", "3.14"]

    steps:
      - name: Check out source
        uses: actions/checkout@v7

      - name: Install uv and Python
        uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
        with:
          version: "0.11.30"
          python-version: ${{ matrix.python-version }}
          enable-cache: true
          cache-dependency-glob: "uv.lock"

      - name: Verify lockfile and sync
        run: uv sync --locked --all-groups --all-extras

      - name: Check formatting
        run: uv run --locked ruff format --check .

      - name: Lint
        run: uv run --locked ruff check .

      - name: Type check
        run: uv run --locked mypy src

      - name: Test
        run: uv run --locked pytest --cov --cov-report=term-missing

      - name: Minimize persistent uv cache
        if: always()
        run: uv cache prune --ci

7.2.1 为什么固定 uv 和 Action

version: "0.11.30" 防止 Runner 某天自动切换 uv 行为。setup-uv 固定到完整提交 SHA,降低 tag 被移动带来的供应链风险。

上例为易读仍使用 actions/checkout@v7。高安全仓库应把所有第三方 Action(包括官方 Action)固定到审核过的完整 SHA,并由 Dependabot/Renovate 提交升级 PR。

7.2.2 为什么用 --locked

CI 的职责是验证仓库状态,不是替开发者生成新锁文件。若 pyproject.tomluv.lock 不一致,--locked 应立即失败:

uv lock
git diff -- pyproject.toml uv.lock

在本地解决并提交,而不是在 workflow 中执行普通 uv lock 后继续。

7.2.3 Python 矩阵如何选择

库项目至少测试:

  • requires-python 的最低支持版本;
  • 团队默认版本;
  • 当前稳定 Python。

应用项目可以只测试实际部署版本,再额外增加升级预演。矩阵中的 3.14 是本文时点示例;复制工作流时应按项目真实支持范围调整。


7.3 拆分快速检查与完整矩阵

在每个 OS/Python 组合重复 Ruff 和 Mypy 往往没有收益。大型项目可拆为:

Pull Request

快速检查
Ruff + Mypy + lock check

测试矩阵
Python × OS

合并门禁

main/tag 构建

受保护发布

快速 job:

  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
        with:
          version: "0.11.30"
          enable-cache: true
      - run: uv lock --check
      - run: uv sync --locked --all-groups
      - run: uv run ruff format --check .
      - run: uv run ruff check .
      - run: uv run mypy src

矩阵 job 只执行必要测试。最终分支保护同时要求两个 job 通过。


7.4 Workspace 的 CI

最可靠的基线是全量安装和测试:

- name: Sync all workspace members
  run: uv sync --locked --all-packages --all-groups

- name: Test all members
  run: uv run --all-packages pytest

- name: Build all publishable members
  run: uv build --all-packages --clear --no-sources

为防止共享环境掩盖未声明依赖,还应为重要成员增加隔离 job:

- name: Test weather-core as a package target
  run: |
    uv sync --locked --package weather-core
    uv run --package weather-core pytest packages/weather-core/tests

大仓库按变更范围优化时,必须包含反向依赖。核心库变化不能只测试核心库自身。


7.5 正确缓存 uv

setup-uv 内置缓存:

- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
  with:
    version: "0.11.30"
    enable-cache: true
    cache-dependency-glob: "uv.lock"

7.5.1 缓存键包含什么

通常至少包含:

  • 操作系统和架构;
  • uv 缓存格式相关信息;
  • uv.lock 哈希;
  • 必要时包含 Python 版本或构建工具输入。

不要缓存整个 .venv 作为跨 Runner 复用策略。虚拟环境含绝对路径、解释器引用和平台二进制;缓存 uv 下载/构建产物,再用锁文件快速重建环境更稳妥。

7.5.2 uv cache prune --ci

CI 结束时:

uv cache prune --ci

它针对 CI 缓存保留更值得复用的本地构建 wheel,清理可快速重新下载的预构建 wheel 和展开的源码分发物。是否能加速取决于项目依赖,不应脱离测量机械添加。

7.5.3 Self-hosted Runner

自托管 Runner 的缓存不会随 job 销毁,可能无限增长。应:

  • 为 Runner 配置明确 UV_CACHE_DIR
  • 定期执行 uv cache prune
  • 监控磁盘和 inode;
  • 不让不同信任级别的仓库共享可写缓存;
  • 严禁手工修改缓存内部文件。

7.6 发布 Job 的权限隔离

测试 job 不需要 id-token: write。发布 job 应独立,并依赖构建验证:

  publish:
    if: startsWith(github.ref, 'refs/tags/v')
    needs: [quality, test]
    runs-on: ubuntu-latest
    environment: pypi
    permissions:
      contents: read
      id-token: write

    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
        with:
          version: "0.11.30"
      - run: uv build --clear --no-sources
      - run: uv publish --trusted-publishing always

进一步改进:

  • 受保护 environment 需要审批;
  • tag 版本必须等于 pyproject.toml 版本;
  • 构建一次,验证后发布同一组不可变产物;
  • 不在发布 job 临时修改版本或锁文件;
  • 使用 OIDC Trusted Publishing,避免长期 PyPI token。

7.7 Docker 中安装 uv

官方提供仅包含 uv 二进制的 distroless 镜像。常见做法:

FROM python:3.12-slim-trixie

COPY --from=ghcr.io/astral-sh/uv:0.11.30 /uv /uvx /bin/

生产环境优先固定镜像 digest:

COPY --from=ghcr.io/astral-sh/uv@sha256:<审核过的摘要> /uv /uvx /bin/

不要复制文档中的示例摘要后长期不更新;应从组织信任的镜像仓库获取、验证并由自动化升级。


7.8 单包项目的生产 Dockerfile

# syntax=docker/dockerfile:1.7

ARG PYTHON_IMAGE=python:3.12-slim-trixie
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.30

FROM ${UV_IMAGE} AS uv-bin

FROM ${PYTHON_IMAGE} AS builder
COPY --from=uv-bin /uv /uvx /bin/

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy

WORKDIR /app

# 依赖层:源码变化不会使其失效
COPY pyproject.toml uv.lock README.md ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev --no-install-project

# 项目层
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev --no-editable

FROM ${PYTHON_IMAGE} AS runtime

RUN groupadd --system app \
    && useradd --system --gid app --home-dir /app app

WORKDIR /app
COPY --from=builder /app/.venv /app/.venv

ENV PATH="/app/.venv/bin:$PATH" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

USER app
ENTRYPOINT ["weather"]
CMD ["Shanghai"]

7.8.1 为什么分两次同步

第一次只复制依赖声明和锁文件:

uv sync --locked --no-dev --no-install-project

它安装传递依赖,不安装频繁变化的当前项目。源码复制后第二次同步安装项目。业务代码变化不会使整个依赖层失效。

7.8.2 为什么使用 UV_LINK_MODE=copy

BuildKit cache mount 与目标 .venv 可能位于不同文件系统,硬链接不可用。设置 copy 可避免链接警告,并确保最终镜像层不依赖已卸载的 cache mount。

7.8.3 为什么最终镜像不包含 uv

运行时只需要 .venv 中的 Python 和入口脚本。将 uv 留在 builder 可缩小攻击面。若生产运维确实需要 uv run,可以复制 uv,但要明确理由。


7.9 .dockerignore 不可省略

.git/
.github/
.venv/
__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
dist/
build/
.env
.env.*
*.pem
*.key

.venv 必须排除:本机环境不可移植,而且可能覆盖容器刚创建的 Linux 环境。敏感文件同时应从 Git 和构建上下文排除;.dockerignore 不是秘密管理系统,只是最后一道防线。


7.10 Workspace 的 Docker 分层

早期依赖层如果只看到根 pyproject.toml,uv 无法验证锁文件是否与所有成员一致。因此官方建议:

# syntax=docker/dockerfile:1.7
FROM python:3.12-slim-trixie AS builder
COPY --from=ghcr.io/astral-sh/uv:0.11.30 /uv /uvx /bin/

ENV UV_LINK_MODE=copy \
    UV_COMPILE_BYTECODE=1
WORKDIR /app

# 此阶段没有成员 pyproject.toml,跳过新鲜度检查和成员安装
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-dev --no-install-workspace

COPY . /app

# 看到完整 workspace 后必须严格校验
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev --no-editable --package weather-cli

这里早期使用 --frozen 不是因为它更严格,而是因为缺少成员元数据,无法执行完整新鲜度检查。复制完整仓库后,最终 --locked 必须成功。


7.11 容器安全与可重复性

7.11.1 基础镜像

  • 固定 Python 小版本或 digest;
  • 定期重建以获取系统安全更新;
  • slim 镜像可能缺编译器和共享库,构建扩展时使用 builder;
  • Alpine 使用 musl,不应假设 manylinux wheel 可直接复用;
  • 选择镜像前检查目标依赖是否提供对应 wheel。

7.11.2 非 root 运行

最终镜像创建专用用户并 USER app。若程序需要写目录,应明确创建并授权,而不是把整个 /app 设为 777。

7.11.3 字节码

UV_COMPILE_BYTECODE=1 可减少首次启动编译成本,但会增加构建时间和镜像体积。短生命周期 CLI 未必收益明显,Web 服务和无服务器冷启动场景应测量后决定。

7.11.4 构建秘密

私有索引凭据不要用 ARGENV 烘焙进镜像层。使用 BuildKit secret mount:

RUN --mount=type=secret,id=uv_index_password \
    UV_INDEX_INTERNAL_PASSWORD="$(cat /run/secrets/uv_index_password)" \
    uv sync --locked --no-dev

真实项目还需提供用户名或 credential provider。构建日志不得回显秘密。


7.12 私有索引配置

[[tool.uv.index]]
name = "internal"
url = "https://packages.example.com/simple"
explicit = true
authenticate = "always"

[tool.uv.sources]
company-weather-sdk = { index = "internal" }

7.12.1 explicit = true

只有通过 [tool.uv.sources] 显式绑定的包才能从该索引安装。这样不会因为添加私有索引,就让所有公共依赖都从私有源搜索。

7.12.2 默认 first-index

uv 默认对一个包停在第一个包含它的索引,并只在该索引的候选版本中解析。这与 pip 常见的合并候选行为不同,目的是降低 dependency confusion 风险。

不要为了“版本更新”随意启用:

uv sync --index-strategy unsafe-best-match

它会合并多个索引候选,更接近 pip,但显著扩大同名恶意包风险。优先修复索引顺序、同步代理或显式包绑定。

7.12.3 凭据环境变量

索引名 internal 对应:

$env:UV_INDEX_INTERNAL_USERNAME = "ci-user"
$env:UV_INDEX_INTERNAL_PASSWORD = "<secret>"
uv sync --locked

名称中的非字母数字会转换为下划线并大写。例如 internal-proxy 对应 UV_INDEX_INTERNAL_PROXY_PASSWORD

authenticate = "always" 适合那些未认证请求会被重定向到公共页面、因而不会返回标准 401 的索引;它要求 uv 在请求前主动寻找凭据。


7.13 企业网络、证书与离线环境

7.13.1 代理

使用组织标准的 HTTPS_PROXY/HTTP_PROXY 配置,并确认代理不会破坏包哈希和 TLS 验证。CI Secret 中的代理凭据同样不能打印。

7.13.2 企业 CA

优先让 Runner/容器信任组织 CA。uv 支持使用平台证书存储,但不要把 --allow-insecure-host 当作长期修复;它会降低 TLS 保护。

7.13.3 离线 wheelhouse

可把审核过的 wheel 放在 flat index:

[[tool.uv.index]]
name = "offline"
url = "./wheelhouse"
format = "flat"
explicit = true

离线交付必须覆盖目标平台、Python ABI 和所有传递依赖。只在联网开发机下载一次并不等于完成离线验证。


7.14 供应链控制

7.14.1 时间冷却

[tool.uv]
exclude-newer = "7 days"

冷却期能避免立即采用刚上传的发行物,为社区和安全系统留出观察时间。它会降低更新速度,安全补丁需要例外流程。

7.14.2 SBOM

uv export --format cyclonedx1.5 --output-file sbom.json

SBOM 应与具体提交、锁文件和构建产物关联。它列出组件,不自动判断漏洞是否可利用。

7.14.3 发布证明

PyPI Trusted Publishing、容器 provenance 和签名各自解决不同问题:身份、构建来源和产物完整性。生产流程应保存:

  • Git commit/tag;
  • CI run ID;
  • uv.lock 哈希;
  • wheel/sdist/image digest;
  • SBOM 和扫描结果;
  • 发布环境审批记录。

7.15 常见故障

CI 本地通过但 Runner 失败

按顺序检查:

  1. CI Python 是否在 requires-python 范围内;
  2. 是否提交了最新 uv.lock
  3. 本地是否依赖未声明的全局包;
  4. 目标平台是否有兼容 wheel 或编译工具;
  5. 私有索引和凭据是否只在本机配置;
  6. 删除缓存后是否仍失败。

Docker 每次都重新安装依赖

确认 COPY . /app 没有发生在依赖层之前。先复制 pyproject.tomluv.lock 和构建元数据,再执行 --no-install-project

容器出现跨文件系统链接警告

在 BuildKit cache mount 场景设置:

ENV UV_LINK_MODE=copy

Workspace 早期依赖层报锁文件过期

早期层缺少成员元数据,使用 --frozen --no-install-workspace;复制完整 workspace 后必须执行 --locked

私有包解析到了公共 PyPI

使用 explicit = true[tool.uv.sources] 将包绑定到命名索引,检查索引优先级,不要用 unsafe-best-match 掩盖配置问题。

私有索引持续 401/403

检查环境变量名称转换、token 权限、索引 URL 是否以 /simple 结尾、代理和 CA;需要主动认证的索引设置 authenticate = "always"


7.16 生产验收清单

CI

  • uv、Python 和第三方 Action 版本固定且有升级流程。
  • uv sync --locked 在空缓存 Runner 上成功。
  • 最低支持 Python 和生产 Python 都有测试。
  • 测试 job 只有只读权限,发布权限位于独立受保护 job。
  • 缓存键包含锁文件,删除缓存不影响正确性。

Docker

  • .dockerignore 排除 .venv、Git、缓存和秘密。
  • 依赖层与源码层分开。
  • 最终镜像以非 root 用户运行。
  • 基础镜像和 uv 镜像固定版本/digest。
  • 私有索引秘密通过 secret mount 提供,不进入镜像历史。
  • 从最终镜像执行健康检查或 CLI 冒烟测试。

供应链

  • 内部包显式绑定私有索引。
  • 保持默认 first-index,例外经过安全评审。
  • 发布优先使用 OIDC,没有长期 PyPI token。
  • 产物、SBOM、commit 和 CI run 能互相追踪。
  • 构建产物经过漏洞、许可证和秘密扫描。

7.17 本篇小结

生产级 uv 流程的重点不是“CI 里也能运行 uv sync”,而是把锁文件当作不可变输入、把缓存当作可丢弃加速层、把测试与发布权限分离,并保证 Docker 最终镜像只包含运行必需内容。私有索引的 explicit 绑定和默认 first-index 则为 Python 依赖供应链提供了重要边界。

下一篇将给出从 pip/pip-tools、Poetry、PDM 和 Pipenv 迁移的分阶段方案,并建立覆盖解释器、解析、构建、网络和缓存的系统排障方法。

官方参考

更多推荐