Recompress slim layers to gzip before pushing #115
This file contains hidden or 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
| name: Docker Image | |
| on: | |
| # When a release is published | |
| release: | |
| types: [published] | |
| # Push excluding tags and Markdown-only changes | |
| push: | |
| branches: | |
| - main | |
| tags-ignore: | |
| - '*.*' | |
| paths-ignore: | |
| - '**/*.md' | |
| # Validate pull requests without publishing | |
| pull_request: | |
| branches: | |
| - main | |
| paths-ignore: | |
| - '**/*.md' | |
| # Manual trigger | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| security-events: write | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| IMAGE_NAME: ${{ vars.DOCKERHUB_NAMESPACE || github.repository_owner }}/postgresql | |
| jobs: | |
| build_arch_images: | |
| name: Build ${{ matrix.arch }} | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - arch: amd64 | |
| platform: linux/amd64 | |
| - arch: arm64 | |
| platform: linux/arm64 | |
| outputs: | |
| version: ${{ steps.meta.outputs.version }} | |
| is_versioned: ${{ steps.meta.outputs.is_versioned }} | |
| steps: | |
| - name: Free up disk space | |
| shell: bash | |
| run: | | |
| echo "Disk space before cleanup:" | |
| df -h | |
| # Remove large directories | |
| sudo rm -rf /usr/share/dotnet \ | |
| /usr/local/lib/android \ | |
| /opt/ghc \ | |
| /usr/local/.ghcup \ | |
| /opt/hostedtoolcache/CodeQL || true | |
| # Remove large packages | |
| sudo apt-get remove -y '^aspnetcore-.*' '^dotnet-.*' '^llvm-.*' 'php.*' \ | |
| '^mongodb-.*' '^mysql-.*' azure-cli google-chrome-stable firefox \ | |
| powershell mono-devel libgl1-mesa-dri google-cloud-sdk google-cloud-cli || true | |
| sudo apt-get autoremove -y | |
| sudo apt-get clean | |
| # Remove Docker images | |
| sudo docker image prune --all --force || true | |
| # Remove swap storage | |
| sudo swapoff -a || true | |
| sudo rm -f /mnt/swapfile || true | |
| echo "Disk space after cleanup:" | |
| df -h | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 | |
| with: | |
| platforms: all | |
| - name: Set up Buildx | |
| uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 | |
| - name: Log in to Docker Hub | |
| if: github.event_name != 'pull_request' | |
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| with: | |
| username: ${{ secrets._TEMP_DOCKERHUB_USER }} | |
| password: ${{ secrets._TEMP_DOCKERHUB_PASSWORD }} | |
| - name: Compute image metadata | |
| id: meta | |
| shell: bash | |
| run: | | |
| VERSION="" | |
| IS_VERSIONED="false" | |
| if [[ "${{ github.event_name }}" == "release" ]]; then | |
| VERSION="${{ github.event.release.tag_name }}" | |
| IS_VERSIONED="true" | |
| elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then | |
| if [[ "${{ github.ref_type }}" == "tag" ]]; then | |
| VERSION="${{ github.ref_name }}" | |
| IS_VERSIONED="true" | |
| elif [[ "${{ github.ref }}" != "refs/heads/main" ]]; then | |
| echo "workflow_dispatch must be run on main or on a tag" | |
| exit 1 | |
| fi | |
| fi | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| echo "is_versioned=$IS_VERSIONED" >> "$GITHUB_OUTPUT" | |
| - name: Build base image (PR — load only) | |
| if: github.event_name == 'pull_request' | |
| uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 | |
| with: | |
| context: . | |
| platforms: ${{ matrix.platform }} | |
| provenance: false | |
| sbom: false | |
| outputs: type=docker,name=local-ci-image:latest | |
| build-args: | | |
| GIT_COMMIT=${{ github.sha }} | |
| cache-from: type=gha,scope=postgresql-${{ matrix.arch }} | |
| cache-to: type=gha,mode=max,scope=postgresql-${{ matrix.arch }} | |
| no-cache-filters: | | |
| trimmed | |
| trimmed-all | |
| - name: Build base image (load + push by digest) | |
| id: build_base | |
| if: github.event_name != 'pull_request' | |
| uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 | |
| with: | |
| context: . | |
| platforms: ${{ matrix.platform }} | |
| provenance: false | |
| sbom: false | |
| outputs: | | |
| type=docker,name=local-ci-image:latest | |
| type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true | |
| build-args: | | |
| GIT_COMMIT=${{ github.sha }} | |
| cache-from: type=gha,scope=postgresql-${{ matrix.arch }} | |
| cache-to: type=gha,mode=max,scope=postgresql-${{ matrix.arch }} | |
| no-cache-filters: | | |
| trimmed | |
| trimmed-all | |
| - name: Scan base Docker image | |
| uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0 | |
| id: anchore-scan | |
| with: | |
| image: local-ci-image:latest | |
| fail-build: false | |
| severity-cutoff: critical | |
| - name: Upload Anchore scan SARIF report | |
| if: ${{ !cancelled() && github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') }} | |
| uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1 | |
| with: | |
| sarif_file: ${{ steps.anchore-scan.outputs.sarif }} | |
| category: grype-${{ matrix.arch }} | |
| - name: Install slim toolkit | |
| shell: bash | |
| run: | | |
| curl -sL https://raw.githubusercontent.com/slimtoolkit/slim/master/scripts/install-slim.sh | sudo -E bash - | |
| - name: Slim the image | |
| shell: bash | |
| run: | | |
| chmod +x ./slim-image.sh | |
| ./slim-image.sh local-ci-image:latest local-ci-image-slim:latest "${{ matrix.arch }}" | |
| # The slim image lives in the host docker daemon (slim-toolkit's output). | |
| # Buildx can't push it by digest: the `docker` driver reads docker-daemon | |
| # images but doesn't support push-by-digest, while the `docker-container` | |
| # driver supports push-by-digest but can't reach the docker daemon. We | |
| # use regctl instead — it can push from a local OCI layout to a registry | |
| # `repo@sha256:…` URL, leaving no tag in the registry. | |
| # | |
| # Cosign is installed first so regctl-installer can verify the binary's | |
| # sigstore signature (the action's verification is opportunistic — it | |
| # silently skips if cosign isn't on PATH). | |
| - name: Install cosign | |
| if: github.event_name != 'pull_request' | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| - name: Install regctl | |
| if: github.event_name != 'pull_request' | |
| uses: regclient/actions/regctl-installer@1b705e32d40851370799ea5814e83d0a5f6a70dc # v0.1.0 | |
| with: | |
| release: v0.11.3 | |
| - name: Push slim image by digest | |
| id: push_slim | |
| if: github.event_name != 'pull_request' | |
| shell: bash | |
| env: | |
| DOCKERHUB_USER: ${{ secrets._TEMP_DOCKERHUB_USER }} | |
| DOCKERHUB_PASSWORD: ${{ secrets._TEMP_DOCKERHUB_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| cleanup() { | |
| rm -f /tmp/slim.tar | |
| rm -rf /tmp/slim-oci | |
| } | |
| trap cleanup EXIT | |
| # regctl maintains its own credential store, separate from `docker login`. | |
| echo "${DOCKERHUB_PASSWORD}" \ | |
| | regctl registry login docker.io --user "${DOCKERHUB_USER}" --pass-stdin | |
| # Defense-in-depth: assert the credential persisted. Silent fallback | |
| # to anonymous would let the push fail with an opaque permission error. | |
| if ! regctl registry config docker.io | grep -q '"user":'; then | |
| echo "regctl login did not persist user credential for docker.io" >&2 | |
| exit 1 | |
| fi | |
| # docker save → OCI layout: regctl needs a storage backend it understands; | |
| # the docker daemon isn't one, so we stage the image on disk first. | |
| docker save local-ci-image-slim:latest -o /tmp/slim.tar | |
| regctl image import "ocidir:///tmp/slim-oci:slim" /tmp/slim.tar | |
| rm /tmp/slim.tar | |
| # docker save writes uncompressed layer tarballs (.tar, not .tar.gzip). | |
| # Pushed as-is the slim image lands in the registry larger than the | |
| # un-slimmed base. Recompress layers to gzip in place so the registry | |
| # blob sizes reflect the slim toolkit's actual size win. | |
| regctl image mod "ocidir:///tmp/slim-oci:slim" \ | |
| --layer-compress gzip --replace | |
| # Compute the manifest digest locally so we can address the registry push | |
| # by digest URL — no tag is created on Docker Hub. Must happen AFTER the | |
| # mod step: recompressing layers changes layer digests → manifest digest. | |
| DIGEST="$(regctl manifest digest "ocidir:///tmp/slim-oci:slim")" | |
| echo "Slim digest (${{ matrix.arch }}): ${DIGEST}" | |
| # Retry on transient registry errors (5xx, connection resets). A failed | |
| # push otherwise costs a full ~15-minute matrix re-run. | |
| for attempt in 1 2 3; do | |
| if regctl image copy "ocidir:///tmp/slim-oci:slim" "${IMAGE_NAME}@${DIGEST}"; then | |
| break | |
| fi | |
| if [ "${attempt}" -eq 3 ]; then | |
| echo "regctl image copy failed after 3 attempts" >&2 | |
| exit 1 | |
| fi | |
| sleep_for=$((attempt * 5)) | |
| echo "regctl image copy attempt ${attempt} failed, retrying in ${sleep_for}s..." >&2 | |
| sleep "${sleep_for}" | |
| done | |
| echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" | |
| - name: Save digests | |
| if: github.event_name != 'pull_request' | |
| shell: bash | |
| run: | | |
| mkdir -p /tmp/digests | |
| echo "${{ steps.build_base.outputs.digest }}" > "/tmp/digests/base-${{ matrix.arch }}" | |
| echo "${{ steps.push_slim.outputs.digest }}" > "/tmp/digests/slim-${{ matrix.arch }}" | |
| - name: Upload digests | |
| if: github.event_name != 'pull_request' | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: digests-${{ matrix.arch }} | |
| path: /tmp/digests/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| create_manifests: | |
| name: Create manifests | |
| needs: build_arch_images | |
| if: github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Set up Buildx | |
| uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 | |
| - name: Log in to Docker Hub | |
| uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 | |
| with: | |
| username: ${{ secrets._TEMP_DOCKERHUB_USER }} | |
| password: ${{ secrets._TEMP_DOCKERHUB_PASSWORD }} | |
| - name: Download digests | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| path: /tmp/digests | |
| pattern: digests-* | |
| merge-multiple: true | |
| - name: Create manifest tags | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| VERSION="${{ needs.build_arch_images.outputs.version }}" | |
| IS_VERSIONED="${{ needs.build_arch_images.outputs.is_versioned }}" | |
| BASE_AMD64="${IMAGE_NAME}@$(cat /tmp/digests/base-amd64)" | |
| BASE_ARM64="${IMAGE_NAME}@$(cat /tmp/digests/base-arm64)" | |
| SLIM_AMD64="${IMAGE_NAME}@$(cat /tmp/digests/slim-amd64)" | |
| SLIM_ARM64="${IMAGE_NAME}@$(cat /tmp/digests/slim-arm64)" | |
| if [[ "$IS_VERSIONED" == "true" ]]; then | |
| docker buildx imagetools create \ | |
| -t "${IMAGE_NAME}:${VERSION}" \ | |
| -t "${IMAGE_NAME}:latest" \ | |
| "$BASE_AMD64" "$BASE_ARM64" | |
| docker buildx imagetools create \ | |
| -t "${IMAGE_NAME}:${VERSION}-slim" \ | |
| -t "${IMAGE_NAME}:latest-slim" \ | |
| "$SLIM_AMD64" "$SLIM_ARM64" | |
| else | |
| docker buildx imagetools create \ | |
| -t "${IMAGE_NAME}:develop" \ | |
| "$BASE_AMD64" "$BASE_ARM64" | |
| docker buildx imagetools create \ | |
| -t "${IMAGE_NAME}:develop-slim" \ | |
| "$SLIM_AMD64" "$SLIM_ARM64" | |
| fi |