# Build and publish Docker images. # # Uses Depot for multi-platform builds. Includes both a `uv` base image, which # is just the binary in a scratch image, and a set of extra, common images with # the uv binary installed. # # Images are built on all runs. # # On release, assumed to run as a subworkflow of .github/workflows/release.yml; # specifically, as a local artifacts job within `cargo-dist`. In this case, # images are published based on the `plan`. # # TODO(charlie): Ideally, the publish step would happen as a publish job within # `cargo-dist`, but sharing the built image as an artifact between jobs is # challenging. name: "Docker images" on: workflow_call: inputs: plan: required: true type: string pull_request: paths: # We want to ensure that the maturin builds still work when we change # Project metadata - pyproject.toml - Cargo.toml - .cargo/config.toml # Toolchain or dependency versions - Cargo.lock - rust-toolchain.toml # The Dockerfile itself - Dockerfile # And the workflow itself - .github/workflows/build-docker.yml env: UV_GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/uv UV_DOCKERHUB_IMAGE: docker.io/astral/uv permissions: {} jobs: docker-plan: name: plan runs-on: ubuntu-latest outputs: login: ${{ steps.plan.outputs.login }} push: ${{ steps.plan.outputs.push }} tag: ${{ steps.plan.outputs.tag }} action: ${{ steps.plan.outputs.action }} steps: - name: Set push variable env: DRY_RUN: ${{ inputs.plan == '' || fromJson(inputs.plan).announcement_tag_is_implicit }} TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag }} IS_LOCAL_PR: ${{ github.event.pull_request.head.repo.full_name == 'astral-sh/uv' }} id: plan run: | if [ "${DRY_RUN}" == "false" ]; then echo "login=true" >> "$GITHUB_OUTPUT" echo "push=true" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "action=build and publish" >> "$GITHUB_OUTPUT" else echo "login=${IS_LOCAL_PR}" >> "$GITHUB_OUTPUT" echo "push=false" >> "$GITHUB_OUTPUT" echo "tag=dry-run" >> "$GITHUB_OUTPUT" echo "action=build" >> "$GITHUB_OUTPUT" fi docker-publish-base: if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-build') }} name: ${{ needs.docker-plan.outputs.action }} uv needs: - docker-plan runs-on: ubuntu-latest permissions: contents: read id-token: write # for Depot OIDC and GHCR signing packages: write # for GHCR image pushes attestations: write # for GHCR attestations environment: name: ${{ needs.docker-plan.outputs.push == 'true' && 'release' || '' }} outputs: image-tags: ${{ steps.meta.outputs.tags }} image-annotations: ${{ steps.meta.outputs.annotations }} image-digest: ${{ steps.build.outputs.digest }} image-version: ${{ steps.meta.outputs.version }} steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: submodules: recursive persist-credentials: false # Login to DockerHub (when not pushing, it's to avoid rate-limiting) - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 if: ${{ needs.docker-plan.outputs.login == 'true' }} with: username: ${{ needs.docker-plan.outputs.push == 'true' && 'astral' || 'astralshbot' }} password: ${{ needs.docker-plan.outputs.push == 'true' && secrets.DOCKERHUB_TOKEN_RW || secrets.DOCKERHUB_TOKEN_RO }} - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 - name: Check tag consistency if: ${{ needs.docker-plan.outputs.push == 'true' }} run: | version=$(grep "version = " pyproject.toml | sed -e 's/version = "\(.*\)"/\1/g') if [ "${TAG}" != "${version}" ]; then echo "The input tag does not match the version from pyproject.toml:" >&2 echo "${TAG}" >&2 echo "${version}" >&2 exit 1 else echo "Releasing ${version}" fi env: TAG: ${{ needs.docker-plan.outputs.tag }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 env: DOCKER_METADATA_ANNOTATIONS_LEVELS: index with: images: | ${{ env.UV_GHCR_IMAGE }} ${{ env.UV_DOCKERHUB_IMAGE }} # Defining this makes sure the org.opencontainers.image.version OCI label becomes the actual release version and not the branch name tags: | type=raw,value=dry-run,enable=${{ needs.docker-plan.outputs.push == 'false' }} type=pep440,pattern={{ version }},value=${{ needs.docker-plan.outputs.tag }},enable=${{ needs.docker-plan.outputs.push }} type=pep440,pattern={{ major }}.{{ minor }},value=${{ needs.docker-plan.outputs.tag }},enable=${{ needs.docker-plan.outputs.push }} - name: Build and push by digest id: build uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2 with: project: 7hd4vdzmw5 # astral-sh/uv context: . platforms: linux/amd64,linux/arm64 push: ${{ needs.docker-plan.outputs.push }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # TODO(zanieb): Annotations are not supported by Depot yet and are ignored annotations: ${{ steps.meta.outputs.annotations }} - name: Generate artifact attestation for base image if: ${{ needs.docker-plan.outputs.push == 'true' }} uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-name: ${{ env.UV_GHCR_IMAGE }} subject-digest: ${{ steps.build.outputs.digest }} docker-publish-extra: name: ${{ needs.docker-plan.outputs.action }} ${{ matrix.image-mapping }} runs-on: ubuntu-latest environment: name: ${{ needs.docker-plan.outputs.push == 'true' && 'release' || '' }} needs: - docker-plan - docker-publish-base permissions: id-token: write # for Depot OIDC and GHCR signing packages: write # for GHCR image pushes attestations: write # for GHCR attestations strategy: fail-fast: false matrix: # Mapping of base image followed by a comma followed by one or more base tags (comma separated) # Note, org.opencontainers.image.version label will use the first base tag (use the most specific tag first) image-mapping: - alpine:3.22,alpine3.22,alpine - alpine:3.21,alpine3.21 - debian:trixie-slim,trixie-slim,debian-slim - buildpack-deps:trixie,trixie,debian - debian:bookworm-slim,bookworm-slim - buildpack-deps:bookworm,bookworm - python:3.14-alpine,python3.14-alpine - python:3.13-alpine,python3.13-alpine - python:3.12-alpine,python3.12-alpine - python:3.11-alpine,python3.11-alpine - python:3.10-alpine,python3.10-alpine - python:3.9-alpine,python3.9-alpine - python:3.8-alpine,python3.8-alpine - python:3.14-trixie,python3.14-trixie - python:3.13-trixie,python3.13-trixie - python:3.12-trixie,python3.12-trixie - python:3.11-trixie,python3.11-trixie - python:3.10-trixie,python3.10-trixie - python:3.9-trixie,python3.9-trixie - python:3.14-slim-trixie,python3.14-trixie-slim - python:3.13-slim-trixie,python3.13-trixie-slim - python:3.12-slim-trixie,python3.12-trixie-slim - python:3.11-slim-trixie,python3.11-trixie-slim - python:3.10-slim-trixie,python3.10-trixie-slim - python:3.9-slim-trixie,python3.9-trixie-slim - python:3.14-bookworm,python3.14-bookworm - python:3.13-bookworm,python3.13-bookworm - python:3.12-bookworm,python3.12-bookworm - python:3.11-bookworm,python3.11-bookworm - python:3.10-bookworm,python3.10-bookworm - python:3.9-bookworm,python3.9-bookworm - python:3.8-bookworm,python3.8-bookworm - python:3.14-slim-bookworm,python3.14-bookworm-slim - python:3.13-slim-bookworm,python3.13-bookworm-slim - python:3.12-slim-bookworm,python3.12-bookworm-slim - python:3.11-slim-bookworm,python3.11-bookworm-slim - python:3.10-slim-bookworm,python3.10-bookworm-slim - python:3.9-slim-bookworm,python3.9-bookworm-slim - python:3.8-slim-bookworm,python3.8-bookworm-slim steps: # Login to DockerHub (when not pushing, it's to avoid rate-limiting) - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 if: ${{ needs.docker-plan.outputs.login == 'true' }} with: username: ${{ needs.docker-plan.outputs.push == 'true' && 'astral' || 'astralshbot' }} password: ${{ needs.docker-plan.outputs.push == 'true' && secrets.DOCKERHUB_TOKEN_RW || secrets.DOCKERHUB_TOKEN_RO }} - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 - name: Generate Dynamic Dockerfile Tags shell: bash run: | set -euo pipefail # Extract the image and tags from the matrix variable IFS=',' read -r BASE_IMAGE BASE_TAGS <<< "${{ matrix.image-mapping }}" # Generate Dockerfile content cat < Dockerfile FROM ${BASE_IMAGE} COPY --from=${UV_GHCR_IMAGE}:latest /uv /uvx /usr/local/bin/ ENV UV_TOOL_BIN_DIR="/usr/local/bin" ENTRYPOINT [] CMD ["/usr/local/bin/uv"] EOF # Initialize a variable to store all tag docker metadata patterns TAG_PATTERNS="" # Loop through all base tags and append its docker metadata pattern to the list # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version IFS=','; for TAG in ${BASE_TAGS}; do TAG_PATTERNS="${TAG_PATTERNS}type=pep440,pattern={{ version }},suffix=-${TAG},value=${VERSION}\n" TAG_PATTERNS="${TAG_PATTERNS}type=pep440,pattern={{ major }}.{{ minor }},suffix=-${TAG},value=${VERSION}\n" TAG_PATTERNS="${TAG_PATTERNS}type=raw,value=${TAG}\n" done # Remove the trailing newline from the pattern list TAG_PATTERNS="${TAG_PATTERNS%\\n}" # Export tag patterns using the multiline env var syntax { echo "TAG_PATTERNS<> $GITHUB_ENV env: VERSION: ${{ needs.docker-plan.outputs.tag }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 # ghcr.io prefers index level annotations env: DOCKER_METADATA_ANNOTATIONS_LEVELS: index with: images: | ${{ env.UV_GHCR_IMAGE }} ${{ env.UV_DOCKERHUB_IMAGE }} flavor: | latest=false tags: | ${{ env.TAG_PATTERNS }} - name: Build and push id: build-and-push uses: depot/build-push-action@9785b135c3c76c33db102e45be96a25ab55cd507 # v1.16.2 with: context: . project: 7hd4vdzmw5 # astral-sh/uv platforms: linux/amd64,linux/arm64 push: ${{ needs.docker-plan.outputs.push }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # TODO(zanieb): Annotations are not supported by Depot yet and are ignored annotations: ${{ steps.meta.outputs.annotations }} - name: Generate artifact attestation if: ${{ needs.docker-plan.outputs.push == 'true' }} uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-name: ${{ env.UV_GHCR_IMAGE }} subject-digest: ${{ steps.build-and-push.outputs.digest }} # Push annotations manually. # See `docker-annotate-base` for details. - name: Add annotations to images if: ${{ needs.docker-plan.outputs.push == 'true' }} env: IMAGES: "${{ env.UV_GHCR_IMAGE }} ${{ env.UV_DOCKERHUB_IMAGE }}" DIGEST: ${{ steps.build-and-push.outputs.digest }} TAGS: ${{ steps.meta.outputs.tags }} ANNOTATIONS: ${{ steps.meta.outputs.annotations }} run: | set -x readarray -t lines <<< "$ANNOTATIONS"; annotations=(); for line in "${lines[@]}"; do annotations+=(--annotation "$line"); done for image in $IMAGES; do readarray -t lines < <(grep "^${image}:" <<< "$TAGS"); tags=(); for line in "${lines[@]}"; do tags+=(-t "$line"); done docker buildx imagetools create \ "${annotations[@]}" \ "${tags[@]}" \ "${image}@${DIGEST}" done # See `docker-annotate-base` for details. - name: Export manifest digest id: manifest-digest if: ${{ needs.docker-plan.outputs.push == 'true' }} env: IMAGE: ${{ env.UV_GHCR_IMAGE }} VERSION: ${{ steps.meta.outputs.version }} run: | digest="$( docker buildx imagetools inspect \ "${IMAGE}:${VERSION}" \ --format '{{json .Manifest}}' \ | jq -r '.digest' )" echo "digest=${digest}" >> "$GITHUB_OUTPUT" # See `docker-annotate-base` for details. - name: Generate artifact attestation if: ${{ needs.docker-plan.outputs.push == 'true' }} uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-name: ${{ env.UV_GHCR_IMAGE }} subject-digest: ${{ steps.manifest-digest.outputs.digest }} # Annotate the base image docker-annotate-base: name: annotate uv runs-on: ubuntu-latest permissions: contents: read id-token: write # for GHCR signing packages: write # for GHCR image pushes attestations: write # for GHCR attestations environment: name: ${{ needs.docker-plan.outputs.push == 'true' && 'release' || '' }} needs: - docker-plan - docker-publish-base - docker-publish-extra if: ${{ needs.docker-plan.outputs.push == 'true' }} steps: - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: astral password: ${{ secrets.DOCKERHUB_TOKEN_RW }} - uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} # Depot doesn't support annotating images, so we need to do so manually # afterwards. Mutating the manifest is desirable regardless, because we # want to bump the base image to appear at the top of the list on GHCR. # However, once annotation support is added to Depot, this step can be # minimized to just touch the GHCR manifest. - name: Add annotations to images env: IMAGES: "${{ env.UV_GHCR_IMAGE }} ${{ env.UV_DOCKERHUB_IMAGE }}" DIGEST: ${{ needs.docker-publish-base.outputs.image-digest }} TAGS: ${{ needs.docker-publish-base.outputs.image-tags }} ANNOTATIONS: ${{ needs.docker-publish-base.outputs.image-annotations }} # The readarray part is used to make sure the quoting and special characters are preserved on expansion (e.g. spaces) # The final command becomes `docker buildx imagetools create --annotation 'index:foo=1' --annotation 'index:bar=2' ... -t tag1 -t tag2 ... @sha256:` run: | set -x readarray -t lines <<< "$ANNOTATIONS"; annotations=(); for line in "${lines[@]}"; do annotations+=(--annotation "$line"); done for image in $IMAGES; do readarray -t lines < <(grep "^${image}:" <<< "$TAGS"); tags=(); for line in "${lines[@]}"; do tags+=(-t "$line"); done docker buildx imagetools create \ "${annotations[@]}" \ "${tags[@]}" \ "${image}@${DIGEST}" done # Now that we've modified the manifest, we need to attest it again. # Note we only generate an attestation for GHCR. - name: Export manifest digest id: manifest-digest env: IMAGE: ${{ env.UV_GHCR_IMAGE }} VERSION: ${{ needs.docker-publish-base.outputs.image-version }} # To sign the manifest, we need it's digest. Unfortunately "docker # buildx imagetools create" does not (yet) have a clean way of sharing # the digest of the manifest it creates (see docker/buildx#2407), so # we use a separate command to retrieve it. # imagetools inspect [TAG] --format '{{json .Manifest}}' gives us # the machine readable JSON description of the manifest, and the # jq command extracts the digest from this. The digest is then # sent to the Github step output file for sharing with other steps. run: | digest="$( docker buildx imagetools inspect \ "${IMAGE}:${VERSION}" \ --format '{{json .Manifest}}' \ | jq -r '.digest' )" echo "digest=${digest}" >> "$GITHUB_OUTPUT" - name: Generate artifact attestation uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-name: ${{ env.UV_GHCR_IMAGE }} subject-digest: ${{ steps.manifest-digest.outputs.digest }}