Skip to content
Latchkey LogoLatchkey home

[ruff] Build Docker image workflow (astral-sh/ruff)

The [ruff] Build Docker image workflow from astral-sh/ruff, explained and optimized by Latchkey.

Source: astral-sh/ruff.github/workflows/build-docker.ymlLicense MITView source
C
CI health grade
69/100
Health score
4
Jobs per trigger
3
Fixes in the optimized version

In short

  1. CI health: C (fair)
  2. The optimized version below points runs-on at Latchkey managed runners and adds run de-duplication and job timeouts.

What it does

This is the [ruff] Build Docker image workflow from the astral-sh/ruff repository, a real project running GitHub Actions. It is shown here with attribution under its MIT license.

Below, Latchkey shows a faster, safer version produced by its optimization engine.

The workflow

workflow (.yml)
# Build and publish a Docker image.
#
# Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a local
# artifacts job within `cargo-dist`.
#
# 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: "[ruff] Build Docker image"

on:
  workflow_call:
    inputs:
      plan:
        required: true
        type: string
  pull_request:
    paths:
      - .github/workflows/build-docker.yml

env:
  RUFF_BASE_IMG: ghcr.io/${{ github.repository_owner }}/ruff

permissions:
  contents: read
  # TODO(zanieb): Ideally, this would be `read` on dry-run but that will require
  # significant changes to the workflow.
  packages: write # zizmor: ignore[excessive-permissions]

jobs:
  docker-build:
    name: Build Docker image (ghcr.io/astral-sh/ruff) for ${{ matrix.platform }}
    runs-on: ubuntu-latest
    environment:
      name: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit && 'release' || '' }}
    strategy:
      fail-fast: false
      matrix:
        platform:
          - linux/amd64
          - linux/arm64
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          submodules: recursive
          persist-credentials: false

      - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

      - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
        if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Check tag consistency
        if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
        env:
          TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }}
        run: |
          version=$(grep -m 1 "^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

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
        with:
          images: ${{ env.RUFF_BASE_IMG }}
          # 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=${{ inputs.plan == '' || fromJson(inputs.plan).announcement_tag_is_implicit }}
            type=pep440,pattern={{ version }},value=${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }},enable=${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}

      - name: Normalize Platform Pair (replace / with -)
        run: |
          platform=${{ matrix.platform }}
          echo "PLATFORM_TUPLE=${platform//\//-}" >> "$GITHUB_ENV"

      # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/
      - name: Build and push by digest
        id: build
        uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
        with:
          context: .
          platforms: ${{ matrix.platform }}
          cache-from: type=gha,scope=ruff-${{ env.PLATFORM_TUPLE }}
          cache-to: type=gha,mode=min,scope=ruff-${{ env.PLATFORM_TUPLE }}
          labels: ${{ steps.meta.outputs.labels }}
          outputs: type=image,name=${{ env.RUFF_BASE_IMG }},push-by-digest=true,name-canonical=true,push=${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}

      - name: Export digests
        env:
          digest: ${{ steps.build.outputs.digest }}
        run: |
          mkdir -p /tmp/digests
          touch "/tmp/digests/${digest#sha256:}"

      - name: Upload digests
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: digests-${{ env.PLATFORM_TUPLE }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1

  docker-publish:
    name: Publish Docker image (ghcr.io/astral-sh/ruff)
    runs-on: ubuntu-latest
    environment:
      name: release
    needs:
      - docker-build
    if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
    permissions:
      attestations: write
      id-token: write
      packages: write
    steps:
      - name: Download digests
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true

      - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
        with:
          images: ${{ env.RUFF_BASE_IMG }}
          # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version
          tags: |
            type=pep440,pattern={{ version }},value=${{ fromJson(inputs.plan).announcement_tag }}
            type=pep440,pattern={{ major }}.{{ minor }},value=${{ fromJson(inputs.plan).announcement_tag }}

      - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/
      - name: Create manifest list and push
        working-directory: /tmp/digests
        # The jq command expands the docker/metadata json "tags" array entry to `-t tag1 -t tag2 ...` for each tag in the array
        # The printf will expand the base image with the `<RUFF_BASE_IMG>@sha256:<sha256> ...` for each sha256 in the directory
        # The final command becomes `docker buildx imagetools create -t tag1 -t tag2 ... <RUFF_BASE_IMG>@sha256:<sha256_1> <RUFF_BASE_IMG>@sha256:<sha256_2> ...`
        run: |
          # shellcheck disable=SC2046
          docker buildx imagetools create \
            $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
            $(printf "${RUFF_BASE_IMG}@sha256:%s " *)

      - name: Export manifest digest
        id: manifest-digest
        env:
          IMAGE: ${{ env.RUFF_BASE_IMG }}
          VERSION: ${{ steps.meta.outputs.version }}
        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@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
        with:
          subject-name: ${{ env.RUFF_BASE_IMG }}
          subject-digest: ${{ steps.manifest-digest.outputs.digest }}

  docker-publish-extra:
    name: Publish additional Docker image based on ${{ matrix.image-mapping }}
    runs-on: ubuntu-latest
    environment:
      name: release
    needs:
      - docker-publish
    if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
    permissions:
      attestations: write
      id-token: write
      packages: write
    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.23,alpine3.23,alpine
          - debian:trixie-slim,trixie-slim,debian-slim
          - buildpack-deps:trixie,trixie,debian
    steps:
      - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

      - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Generate Dynamic Dockerfile Tags
        shell: bash
        env:
          TAG_VALUE: ${{ fromJson(inputs.plan).announcement_tag }}
        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 <<EOF > Dockerfile
          FROM ${BASE_IMAGE}
          COPY --from=${RUFF_BASE_IMG}:latest /ruff /usr/local/bin/ruff
          ENTRYPOINT []
          CMD ["/usr/local/bin/ruff"]
          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=${TAG_VALUE}\n"
            TAG_PATTERNS="${TAG_PATTERNS}type=pep440,pattern={{ major }}.{{ minor }},suffix=-${TAG},value=${TAG_VALUE}\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 image cache name
          echo "IMAGE_REF=${BASE_IMAGE//:/-}" >> "$GITHUB_ENV"

          # Export tag patterns using the multiline env var syntax
          {
            echo "TAG_PATTERNS<<EOF"
            echo -e "${TAG_PATTERNS}"
            echo EOF
          } >> "$GITHUB_ENV"

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
        # ghcr.io prefers index level annotations
        env:
          DOCKER_METADATA_ANNOTATIONS_LEVELS: index
        with:
          images: ${{ env.RUFF_BASE_IMG }}
          flavor: |
            latest=false
          tags: |
            ${{ env.TAG_PATTERNS }}

      - name: Build and push
        id: build-and-push
        uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          # We do not really need to cache here as the Dockerfile is tiny
          #cache-from: type=gha,scope=ruff-${{ env.IMAGE_REF }}
          #cache-to: type=gha,mode=min,scope=ruff-${{ env.IMAGE_REF }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          annotations: ${{ steps.meta.outputs.annotations }}

      - name: Generate artifact attestation
        uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
        with:
          subject-name: ${{ env.RUFF_BASE_IMG }}
          subject-digest: ${{ steps.build-and-push.outputs.digest }}

  # This is effectively a duplicate of `docker-publish` to make https://github.com/astral-sh/ruff/pkgs/container/ruff
  # show the ruff base image first since GitHub always shows the last updated image digests
  # This works by annotating the original digests (previously non-annotated) which triggers an update to ghcr.io
  docker-republish:
    name: Annotate Docker image (ghcr.io/astral-sh/ruff)
    runs-on: ubuntu-latest
    environment:
      name: release
    needs:
      - docker-publish-extra
    if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
    permissions:
      attestations: write
      id-token: write
      packages: write
    steps:
      - name: Download digests
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true

      - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
        env:
          DOCKER_METADATA_ANNOTATIONS_LEVELS: index
        with:
          images: ${{ env.RUFF_BASE_IMG }}
          # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version
          tags: |
            type=pep440,pattern={{ version }},value=${{ fromJson(inputs.plan).announcement_tag }}
            type=pep440,pattern={{ major }}.{{ minor }},value=${{ fromJson(inputs.plan).announcement_tag }}

      - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/
      - name: Create manifest list and push
        working-directory: /tmp/digests
        # The readarray part is used to make sure the quoting and special characters are preserved on expansion (e.g. spaces)
        # The jq command expands the docker/metadata json "tags" array entry to `-t tag1 -t tag2 ...` for each tag in the array
        # The printf will expand the base image with the `<RUFF_BASE_IMG>@sha256:<sha256> ...` for each sha256 in the directory
        # The final command becomes `docker buildx imagetools create -t tag1 -t tag2 ... <RUFF_BASE_IMG>@sha256:<sha256_1> <RUFF_BASE_IMG>@sha256:<sha256_2> ...`
        run: |
          readarray -t lines <<< "$DOCKER_METADATA_OUTPUT_ANNOTATIONS"; annotations=(); for line in "${lines[@]}"; do annotations+=(--annotation "$line"); done

          # shellcheck disable=SC2046
          docker buildx imagetools create \
            "${annotations[@]}" \
            $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
            $(printf "${RUFF_BASE_IMG}@sha256:%s " *)

      - name: Export manifest digest
        id: manifest-digest
        env:
          IMAGE: ${{ env.RUFF_BASE_IMG }}
          VERSION: ${{ steps.meta.outputs.version }}
        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@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
        with:
          subject-name: ${{ env.RUFF_BASE_IMG }}
          subject-digest: ${{ steps.manifest-digest.outputs.digest }}

The same workflow, on Latchkey

Removes redundant runs and caps runaway jobs. Added and changed lines are highlighted.

workflow (.yml), optimized
# Build and publish a Docker image.
#
# Assumed to run as a subworkflow of .github/workflows/release.yml; specifically, as a local
# artifacts job within `cargo-dist`.
#
# 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: "[ruff] Build Docker image"

on:
  workflow_call:
    inputs:
      plan:
        required: true
        type: string
  pull_request:
    paths:
      - .github/workflows/build-docker.yml

env:
  RUFF_BASE_IMG: ghcr.io/${{ github.repository_owner }}/ruff

permissions:
  contents: read
  # TODO(zanieb): Ideally, this would be `read` on dry-run but that will require
  # significant changes to the workflow.
  packages: write # zizmor: ignore[excessive-permissions]

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

jobs:
  docker-build:
    timeout-minutes: 30
    name: Build Docker image (ghcr.io/astral-sh/ruff) for ${{ matrix.platform }}
    runs-on: ubuntu-latest
    runs-on: latchkey-small
    environment:
      name: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit && 'release' || '' }}
    strategy:
      fail-fast: false
      matrix:
        platform:
          - linux/amd64
          - linux/arm64
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          submodules: recursive
          persist-credentials: false

      - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

      - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
        if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Check tag consistency
        if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
        env:
          TAG: ${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }}
        run: |
          version=$(grep -m 1 "^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

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
        with:
          images: ${{ env.RUFF_BASE_IMG }}
          # 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=${{ inputs.plan == '' || fromJson(inputs.plan).announcement_tag_is_implicit }}
            type=pep440,pattern={{ version }},value=${{ inputs.plan != '' && fromJson(inputs.plan).announcement_tag || 'dry-run' }},enable=${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}

      - name: Normalize Platform Pair (replace / with -)
        run: |
          platform=${{ matrix.platform }}
          echo "PLATFORM_TUPLE=${platform//\//-}" >> "$GITHUB_ENV"

      # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/
      - name: Build and push by digest
        id: build
        uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
        with:
          context: .
          platforms: ${{ matrix.platform }}
          cache-from: type=gha,scope=ruff-${{ env.PLATFORM_TUPLE }}
          cache-to: type=gha,mode=min,scope=ruff-${{ env.PLATFORM_TUPLE }}
          labels: ${{ steps.meta.outputs.labels }}
          outputs: type=image,name=${{ env.RUFF_BASE_IMG }},push-by-digest=true,name-canonical=true,push=${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}

      - name: Export digests
        env:
          digest: ${{ steps.build.outputs.digest }}
        run: |
          mkdir -p /tmp/digests
          touch "/tmp/digests/${digest#sha256:}"

      - name: Upload digests
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: digests-${{ env.PLATFORM_TUPLE }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1

  docker-publish:
    timeout-minutes: 30
    name: Publish Docker image (ghcr.io/astral-sh/ruff)
    runs-on: ubuntu-latest
    runs-on: latchkey-small
    environment:
      name: release
    needs:
      - docker-build
    if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
    permissions:
      attestations: write
      id-token: write
      packages: write
    steps:
      - name: Download digests
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true

      - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
        with:
          images: ${{ env.RUFF_BASE_IMG }}
          # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version
          tags: |
            type=pep440,pattern={{ version }},value=${{ fromJson(inputs.plan).announcement_tag }}
            type=pep440,pattern={{ major }}.{{ minor }},value=${{ fromJson(inputs.plan).announcement_tag }}

      - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/
      - name: Create manifest list and push
        working-directory: /tmp/digests
        # The jq command expands the docker/metadata json "tags" array entry to `-t tag1 -t tag2 ...` for each tag in the array
        # The printf will expand the base image with the `<RUFF_BASE_IMG>@sha256:<sha256> ...` for each sha256 in the directory
        # The final command becomes `docker buildx imagetools create -t tag1 -t tag2 ... <RUFF_BASE_IMG>@sha256:<sha256_1> <RUFF_BASE_IMG>@sha256:<sha256_2> ...`
        run: |
          # shellcheck disable=SC2046
          docker buildx imagetools create \
            $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
            $(printf "${RUFF_BASE_IMG}@sha256:%s " *)

      - name: Export manifest digest
        id: manifest-digest
        env:
          IMAGE: ${{ env.RUFF_BASE_IMG }}
          VERSION: ${{ steps.meta.outputs.version }}
        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@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
        with:
          subject-name: ${{ env.RUFF_BASE_IMG }}
          subject-digest: ${{ steps.manifest-digest.outputs.digest }}

  docker-publish-extra:
    timeout-minutes: 30
    name: Publish additional Docker image based on ${{ matrix.image-mapping }}
    runs-on: ubuntu-latest
    runs-on: latchkey-small
    environment:
      name: release
    needs:
      - docker-publish
    if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
    permissions:
      attestations: write
      id-token: write
      packages: write
    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.23,alpine3.23,alpine
          - debian:trixie-slim,trixie-slim,debian-slim
          - buildpack-deps:trixie,trixie,debian
    steps:
      - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

      - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Generate Dynamic Dockerfile Tags
        shell: bash
        env:
          TAG_VALUE: ${{ fromJson(inputs.plan).announcement_tag }}
        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 <<EOF > Dockerfile
          FROM ${BASE_IMAGE}
          COPY --from=${RUFF_BASE_IMG}:latest /ruff /usr/local/bin/ruff
          ENTRYPOINT []
          CMD ["/usr/local/bin/ruff"]
          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=${TAG_VALUE}\n"
            TAG_PATTERNS="${TAG_PATTERNS}type=pep440,pattern={{ major }}.{{ minor }},suffix=-${TAG},value=${TAG_VALUE}\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 image cache name
          echo "IMAGE_REF=${BASE_IMAGE//:/-}" >> "$GITHUB_ENV"

          # Export tag patterns using the multiline env var syntax
          {
            echo "TAG_PATTERNS<<EOF"
            echo -e "${TAG_PATTERNS}"
            echo EOF
          } >> "$GITHUB_ENV"

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
        # ghcr.io prefers index level annotations
        env:
          DOCKER_METADATA_ANNOTATIONS_LEVELS: index
        with:
          images: ${{ env.RUFF_BASE_IMG }}
          flavor: |
            latest=false
          tags: |
            ${{ env.TAG_PATTERNS }}

      - name: Build and push
        id: build-and-push
        uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          # We do not really need to cache here as the Dockerfile is tiny
          #cache-from: type=gha,scope=ruff-${{ env.IMAGE_REF }}
          #cache-to: type=gha,mode=min,scope=ruff-${{ env.IMAGE_REF }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          annotations: ${{ steps.meta.outputs.annotations }}

      - name: Generate artifact attestation
        uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
        with:
          subject-name: ${{ env.RUFF_BASE_IMG }}
          subject-digest: ${{ steps.build-and-push.outputs.digest }}

  # This is effectively a duplicate of `docker-publish` to make https://github.com/astral-sh/ruff/pkgs/container/ruff
  # show the ruff base image first since GitHub always shows the last updated image digests
  # This works by annotating the original digests (previously non-annotated) which triggers an update to ghcr.io
  docker-republish:
    timeout-minutes: 30
    name: Annotate Docker image (ghcr.io/astral-sh/ruff)
    runs-on: ubuntu-latest
    runs-on: latchkey-small
    environment:
      name: release
    needs:
      - docker-publish-extra
    if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }}
    permissions:
      attestations: write
      id-token: write
      packages: write
    steps:
      - name: Download digests
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true

      - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
        env:
          DOCKER_METADATA_ANNOTATIONS_LEVELS: index
        with:
          images: ${{ env.RUFF_BASE_IMG }}
          # Order is on purpose such that the label org.opencontainers.image.version has the first pattern with the full version
          tags: |
            type=pep440,pattern={{ version }},value=${{ fromJson(inputs.plan).announcement_tag }}
            type=pep440,pattern={{ major }}.{{ minor }},value=${{ fromJson(inputs.plan).announcement_tag }}

      - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Adapted from https://docs.docker.com/build/ci/github-actions/multi-platform/
      - name: Create manifest list and push
        working-directory: /tmp/digests
        # The readarray part is used to make sure the quoting and special characters are preserved on expansion (e.g. spaces)
        # The jq command expands the docker/metadata json "tags" array entry to `-t tag1 -t tag2 ...` for each tag in the array
        # The printf will expand the base image with the `<RUFF_BASE_IMG>@sha256:<sha256> ...` for each sha256 in the directory
        # The final command becomes `docker buildx imagetools create -t tag1 -t tag2 ... <RUFF_BASE_IMG>@sha256:<sha256_1> <RUFF_BASE_IMG>@sha256:<sha256_2> ...`
        run: |
          readarray -t lines <<< "$DOCKER_METADATA_OUTPUT_ANNOTATIONS"; annotations=(); for line in "${lines[@]}"; do annotations+=(--annotation "$line"); done

          # shellcheck disable=SC2046
          docker buildx imagetools create \
            "${annotations[@]}" \
            $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
            $(printf "${RUFF_BASE_IMG}@sha256:%s " *)

      - name: Export manifest digest
        id: manifest-digest
        env:
          IMAGE: ${{ env.RUFF_BASE_IMG }}
          VERSION: ${{ steps.meta.outputs.version }}
        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@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
        with:
          subject-name: ${{ env.RUFF_BASE_IMG }}
          subject-digest: ${{ steps.manifest-digest.outputs.digest }}

What changed

  1. Run on Latchkey managed runners with one line (runs-on), which apply the fixes below automatically and self-heal transient failures. Each Linux x64 job moves to the Latchkey size matching its vCPU count, latchkey-small for a standard 2 vCPU runner; macOS, Windows and arm64 jobs stay as written.
  2. Cancel superseded runs when a branch or PR gets a newer push.
  3. Add a job timeout so a hung step cannot burn hours of runner time.

What Latchkey heals here

This workflow has steps that commonly fail on transient issues (network, registries, flaky browsers). On Latchkey managed runners they are detected, retried, and self-healed instead of failing your build:

  • Container pulls and builds

This workflow runs 4 jobs (7 with the matrix expanded) per trigger. All of them run on a standard GitHub-hosted Linux x64 runner, which in a private repository bills $0.006 a minute against $0.0025 on latchkey-small, 58% lower; in a public repository like this one they are free. Estimate the monthly difference with your own run counts.

Actions used in this workflow

  • actions/checkout
  • docker/setup-buildx-action
  • docker/login-action
  • docker/metadata-action
  • docker/build-push-action
  • actions/upload-artifact
  • actions/download-artifact
  • actions/attest-build-provenance

Frequently asked questions

What does the [ruff] Build Docker image workflow (astral-sh/ruff) workflow do?
This is the [ruff] Build Docker image workflow from the astral-sh/ruff repository, a real project running GitHub Actions. It is shown here with attribution under its MIT license.
What CI health grade does this workflow get?
This Docker workflow grades C. Paste your own workflow into the Latchkey grader to see its grade and the exact fixes.
How can I improve this Docker workflow?
Add run de-duplication and job timeouts. Latchkey applies these automatically on managed runners when you point runs-on at Latchkey.

References