Ultraviolet (uv) Integration

uv is an extremely fast Python package and project manager that provides a modern alternative to pip and venv. It provides a lot of features that solve the common problems of Python package management but it also introduces a few quirks that need to be taken into account when using Python Semantic Release.

Important

Prerequisite: Make sure you have run through the Getting Started Guide before proceeding with this guide.

Updating the uv.lock

One of the best features of uv is that it automatically generates a lock file (uv.lock) that contains the exact versions of all the dependencies used in your project. The lock file is generated when you run the uv install command, and it is used to ensure that CI workflows are repeatable and development environments are consistent.

When creating a new release using Python Semantic Release, PSR will update the version in the project’s definition file (e.g., pyproject.toml) to indicate the new version. Unfortunately, this action will cause uv to fail on the next execution because the lock file will be out of sync with the project’s definition file. There are two ways to resolve this issue depending on your preference:

  1. Add a step to your build command: Modify your semantic_release.build_command to include the command to update the lock file and stage it for commit. This is commonly used with the GitHub Action and other CI/CD tools when you are building the artifact at the time of release.

    [tool.semantic_release]
    build_command = """
        uv lock --upgrade-package "$PACKAGE_NAME"
        git add uv.lock
        uv build
    """
    

    The intent of the lock upgrade-package call is ONLY to update the version of your project within the lock file after PSR has updated the version in your project’s definition file (e.g., pyproject.toml). When you are running PSR, you have already tested the project as is and you don’t want to actually update the dependencies if a new one just became available.

    For ease of use, PSR provides the $PACKAGE_NAME environment variable that contains the name of your package from the project’s definition file (pyproject.toml:project.name).

    If you are using the PSR GitHub Action, you will need to add an installation command for uv to the build_command because the action runs in a Docker environment does not include uv by default. The best way to ensure that the correct version of uv is installed is to define the version of uv in an optional dependency list (e.g. build). This will also help with other automated tools like Dependabot or Renovate to keep the version of uv up to date.

    [project.optional-dependencies]
    build = ["uv ~= 0.7.12"]
    
    [tool.semantic_release]
    build_command = """
        python -m pip install -e '.[build]'
        uv lock --upgrade-package "$PACKAGE_NAME"
        git add uv.lock
        uv build
    """
    
  2. Stamp the code first & then separately run release: If you prefer to not modify the build command, then you will need to run the uv lock --upgrade-package <your-package-name> command prior to actually creating the release. Essentially, you will run PSR twice: (1) once to update the version in the project’s definition file, and (2) a second time to generate the release.

    The intent of the uv lock --upgrade-package <your-package-name> command is ONLY to update the version of your project within the lock file after PSR has updated the version in your project’s definition file (e.g., pyproject.toml). When you are running PSR, you have already tested the project as is and you don’t want to actually update the dependencies if a new one just became available.

    # 1. PSR stamps version into files (nothing else)
    # don't build the changelog (especially in update mode)
    semantic-release -v version --skip-build --no-commit --no-tag --no-changelog
    
    # 2. run UV lock as pyproject.toml is updated with the next version
    uv lock --upgrade-package <your-package-name>
    
    # 3. stage the lock file to ensure it is included in the PSR commit
    git add uv.lock
    
    # 4. run PSR fully to create release
    semantic-release -v version
    

Advanced Example

Of course, you can mix and match these 2 approaches as needed. If PSR’s pipeline was using uv, we would have a mixture of the 2 approaches because we run the build in a separate job from the release. In our case, PSR would also need to carry the lock file as a workflow artifact along the pipeline for the release job to commit it. This advanced workflow would look like this:

# File: .tool-versions
uv 0.7.12
# File: .python-version
3.11.11
# File: pyproject.toml
[project.optional-dependencies]
build = ["python-semantic-release ~= 10.0"]
test = ["pytest ~= 8.0"]

[tool.semantic_release]
build_command = """
  uv lock --upgrade-package "$PACKAGE_NAME"
  uv build
"""
# File: .github/workflows/release.yml
on:
  push:
    branches:
      - main

jobs:

  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    env:
      dist_artifacts_name: dist
      dist_artifacts_dir: dist
      lock_file_artifact: uv.lock
    steps:
      - name: Setup | Checkout Repository at workflow sha
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
        with:
          ref: ${{ github.sha }}

      - name: Setup | Force correct release branch on workflow sha
        run: git checkout -B ${{ github.ref_name }}

      - name: Setup | Install uv
        uses: asdf-vm/actions/install@1902764435ca0dd2f3388eea723a4f92a4eb8302  # v4.0.2

      - name: Setup | Install Python & Project dependencies
        run: uv sync --extra build

      - name: Build | Build next version artifacts
        id: version
        env:
          GH_TOKEN: "none"
        run: uv run semantic-release -v version --no-changelog --no-commit --no-tag

      - name: Upload | Distribution Artifacts
        if: ${{ steps.version.outputs.released == 'true' }}
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        with:
          name: ${{ env.dist_artifacts_name }}
          path: ${{ format('{0}/**', env.dist_artifacts_dir) }}
          if-no-files-found: error
          retention-days: 2

      - name: Upload | Lock File Artifact
        if: ${{ steps.version.outputs.released == 'true' }}
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02  # v4.6.2
        with:
          name: ${{ env.lock_file_artifact }}
          path: ${{ env.lock_file_artifact }}
          if-no-files-found: error
          retention-days: 2

    outputs:
      new-release-detected: ${{ steps.version.outputs.released }}
      new-release-version: ${{ steps.version.outputs.version }}
      new-release-tag: ${{ steps.version.outputs.tag }}
      new-release-is-prerelease: ${{ steps.version.outputs.is_prerelease }}
      distribution-artifacts: ${{ env.dist_artifacts_name }}
      lock-file-artifact: ${{ env.lock_file_artifact }}


  test-e2e:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Setup | Checkout Repository
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
        with:
          ref: ${{ github.sha }}
          fetch-depth: 1

      - name: Setup | Download Distribution Artifacts
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093  # v4.3.0
        if: ${{ needs.build.outputs.new-release-detected == 'true' }}
        id: artifact-download
        with:
          name: ${{ needs.build.outputs.distribution-artifacts }}
          path: ./dist

      - name: Setup | Install uv
        uses: asdf-vm/actions/install@1902764435ca0dd2f3388eea723a4f92a4eb8302  # v4.0.2

      - name: Setup | Install Python & Project dependencies
        run: uv sync --extra test

      - name: Setup | Install distribution artifact
        if: ${{ steps.artifact-download.outcome == 'success' }}
        run: |
          uv pip uninstall my-package
          uv pip install dist/python_semantic_release-*.whl

      - name: Test | Run pytest
        run: uv run pytest -vv tests/e2e


  release:
    runs-on: ubuntu-latest
    needs:
      - build
      - test-e2e

    if: ${{ needs.build.outputs.new-release-detected == 'true' }}

    concurrency:
      group: ${{ github.workflow }}-release-${{ github.ref_name }}
      cancel-in-progress: false

    permissions:
      contents: write

    steps:
      - name: Setup | Checkout Repository on Release Branch
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
        with:
          ref: ${{ github.ref_name }}

      - name: Setup | Force release branch to be at workflow sha
        run: git reset --hard ${{ github.sha }}

      - name: Setup | Install uv
        uses: asdf-vm/actions/install@1902764435ca0dd2f3388eea723a4f92a4eb8302  # v4.0.2

      - name: Setup | Install Python & Project dependencies
        run: uv sync --extra build

      - name: Setup | Download Build Artifacts
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093  # v4.3.0
        id: artifact-download
        with:
          name: ${{ needs.build.outputs.distribution-artifacts }}
          path: dist

      - name: Setup | Download Lock File Artifact
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093  # v4.3.0
        with:
          name: ${{ needs.build.outputs.lock-file-artifact }}

      - name: Setup | Stage Lock File for Version Commit
        run: git add uv.lock

      - name: Release | Create Release
        id: release
        shell: bash
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          uv run semantic-release -v --strict version --skip-build
          uv run semantic-release publish

    outputs:
      released: ${{ steps.release.outputs.released }}
      new-release-version: ${{ steps.release.outputs.version }}
      new-release-tag: ${{ steps.release.outputs.tag }}


  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    if: ${{ needs.release.outputs.released == 'true' && github.repository == 'python-semantic-release/my-package' }}
    needs:
      - build
      - release

    environment:
      name: pypi
      url: https://pypi.org/project/my-package/

    permissions:
      id-token: write

    steps:
      - name: Setup | Download Build Artifacts
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093  # v4.3.0
        id: artifact-download
        with:
          name: ${{ needs.build.outputs.distribution-artifacts }}
          path: dist

      - name: Publish package distributions to PyPI
        uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc  # v1.12.4
        with:
          packages-dir: dist
          print-hash: true
          verbose: true