Sign docker images using cosign #61
Workflow file for this run
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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: "Build Docker image" | |
on: | |
workflow_call: | |
inputs: | |
plan: | |
required: true | |
type: string | |
pull_request: | |
paths: | |
- .github/workflows/build-docker.yml | |
env: | |
UV_BASE_IMG: ghcr.io/${{ github.repository_owner }}/uv | |
jobs: | |
docker-build: | |
name: Build Docker image (ghcr.io/astral-sh/uv) for ${{ matrix.platform }} | |
runs-on: ubuntu-latest | |
environment: | |
name: release | |
strategy: | |
fail-fast: false | |
matrix: | |
platform: | |
- linux/amd64 | |
- linux/arm64 | |
steps: | |
- uses: actions/checkout@v4 | |
with: | |
submodules: recursive | |
- uses: docker/setup-buildx-action@v3 | |
- uses: docker/login-action@v3 | |
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 }} | |
run: | | |
version=$(grep "version = " pyproject.toml | sed -e 's/version = "\(.*\)"/\1/g') | |
if [ "${{ fromJson(inputs.plan).announcement_tag }}" != "${version}" ]; then | |
echo "The input tag does not match the version from pyproject.toml:" >&2 | |
echo "${{ fromJson(inputs.plan).announcement_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@v5 | |
with: | |
images: ${{ env.UV_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@v6 | |
with: | |
context: . | |
platforms: ${{ matrix.platform }} | |
cache-from: type=gha,scope=uv-${{ env.PLATFORM_TUPLE }} | |
cache-to: type=gha,mode=min,scope=uv-${{ env.PLATFORM_TUPLE }} | |
labels: ${{ steps.meta.outputs.labels }} | |
outputs: type=image,name=${{ env.UV_BASE_IMG }},push-by-digest=true,name-canonical=true,push=${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} | |
- name: Export digests | |
run: | | |
mkdir -p /tmp/digests | |
digest="${{ steps.build.outputs.digest }}" | |
touch "/tmp/digests/${digest#sha256:}" | |
- name: Upload digests | |
uses: actions/upload-artifact@v4 | |
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/uv) | |
runs-on: ubuntu-latest | |
environment: | |
name: release | |
needs: | |
- docker-build | |
if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} | |
steps: | |
- name: Download digests | |
uses: actions/download-artifact@v4 | |
with: | |
path: /tmp/digests | |
pattern: digests-* | |
merge-multiple: true | |
- uses: docker/setup-buildx-action@v3 | |
- name: Extract metadata (tags, labels) for Docker | |
id: meta | |
uses: docker/metadata-action@v5 | |
with: | |
images: ${{ env.UV_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@v3 | |
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 `<UV_BASE_IMG>@sha256:<sha256> ...` for each sha256 in the directory | |
# The final command becomes `docker buildx imagetools create -t tag1 -t tag2 ... <UV_BASE_IMG>@sha256:<sha256_1> <UV_BASE_IMG>@sha256:<sha256_2> ...` | |
run: | | |
docker buildx imagetools create \ | |
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ | |
$(printf '${{ env.UV_BASE_IMG }}@sha256:%s ' *) | |
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: | |
packages: write | |
id-token: write # needed for signing the images with GitHub OIDC Token | |
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.20,alpine3.20,alpine | |
- debian:bookworm-slim,bookworm-slim,debian-slim | |
- buildpack-deps:bookworm,bookworm,debian | |
- 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.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.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: | |
- uses: sigstore/[email protected] | |
- uses: docker/setup-buildx-action@v3 | |
- uses: docker/login-action@v3 | |
with: | |
registry: ghcr.io | |
username: ${{ github.repository_owner }} | |
password: ${{ secrets.GITHUB_TOKEN }} | |
- 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 <<EOF > Dockerfile | |
FROM ${BASE_IMAGE} | |
COPY --from=${{ env.UV_BASE_IMG }}:latest /uv /uvx /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=${{ fromJson(inputs.plan).announcement_tag }}\n" | |
TAG_PATTERNS="${TAG_PATTERNS}type=pep440,pattern={{ major }}.{{ minor }},suffix=-${TAG},value=${{ fromJson(inputs.plan).announcement_tag }}\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@v5 | |
# ghcr.io prefers index level annotations | |
env: | |
DOCKER_METADATA_ANNOTATIONS_LEVELS: index | |
with: | |
images: ${{ env.UV_BASE_IMG }} | |
flavor: | | |
latest=false | |
tags: | | |
${{ env.TAG_PATTERNS }} | |
- name: Build and push | |
id: build-and-push | |
uses: docker/build-push-action@v6 | |
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=uv-${{ env.IMAGE_REF }} | |
#cache-to: type=gha,mode=min,scope=uv-${{ env.IMAGE_REF }} | |
push: true | |
tags: ${{ steps.meta.outputs.tags }} | |
labels: ${{ steps.meta.outputs.labels }} | |
annotations: ${{ steps.meta.outputs.annotations }} | |
- name: Sign the images with GitHub OIDC Token | |
env: | |
DIGEST: ${{ steps.build-and-push.outputs.digest }} | |
TAGS: ${{ steps.meta.outputs.tags }} | |
run: | | |
images="" | |
for tag in ${TAGS}; do | |
images+="${tag}@${DIGEST} " | |
done | |
cosign sign --yes ${images} | |
# This is effectively a duplicate of `docker-publish` to make https://github.com/astral-sh/uv/pkgs/container/uv | |
# show the uv 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/uv) | |
runs-on: ubuntu-latest | |
environment: | |
name: release | |
needs: | |
- docker-publish-extra | |
if: ${{ inputs.plan != '' && !fromJson(inputs.plan).announcement_tag_is_implicit }} | |
permissions: | |
packages: write | |
id-token: write # needed for signing the images with GitHub OIDC Token | |
steps: | |
- name: Download digests | |
uses: actions/download-artifact@v4 | |
with: | |
path: /tmp/digests | |
pattern: digests-* | |
merge-multiple: true | |
- uses: sigstore/[email protected] | |
- uses: docker/setup-buildx-action@v3 | |
- name: Extract metadata (tags, labels) for Docker | |
id: meta | |
uses: docker/metadata-action@v5 | |
env: | |
DOCKER_METADATA_ANNOTATIONS_LEVELS: index | |
with: | |
images: ${{ env.UV_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@v3 | |
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 `<UV_BASE_IMG>@sha256:<sha256> ...` for each sha256 in the directory | |
# The final command becomes `docker buildx imagetools create -t tag1 -t tag2 ... <UV_BASE_IMG>@sha256:<sha256_1> <UV_BASE_IMG>@sha256:<sha256_2> ...` | |
run: | | |
readarray -t lines <<< "$DOCKER_METADATA_OUTPUT_ANNOTATIONS"; annotations=(); for line in "${lines[@]}"; do annotations+=(--annotation "$line"); done | |
docker buildx imagetools create \ | |
"${annotations[@]}" \ | |
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ | |
$(printf '${{ env.UV_BASE_IMG }}@sha256:%s ' *) | |
- name: Share manifest digest | |
id: manifest-digest | |
# 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 \ | |
"${UV_BASE_IMG}:${DOCKER_METADATA_OUTPUT_VERSION}" \ | |
--format '{{json .Manifest}}' \ | |
| jq -r '.digest' | |
)" | |
echo "digest=${digest}" >> "$GITHUB_OUTPUT" | |
- name: Sign the manifest with GitHub OIDC Token | |
env: | |
DIGEST: ${{ steps.manifest-digest.outputs.digest }} | |
TAGS: ${{ steps.meta.outputs.tags }} | |
run: | | |
images="" | |
for tag in ${TAGS}; do | |
images+="${tag}@${DIGEST} " | |
done | |
cosign sign --yes ${images} |