oximg 0.7.5

High-performance image compression: library, CLI, and self-hostable server (PoC).
Documentation
name: Docker

on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:

jobs:
  image:
    permissions:
      contents: read
      packages: write
    strategy:
      matrix:
        include:
          - runner: ubuntu-24.04
            arch: amd64
          - runner: ubuntu-24.04-arm
            arch: arm64
    runs-on: ${{ matrix.runner }}
    steps:
      - uses: actions/checkout@v5
      - uses: docker/setup-buildx-action@v3
      - name: Build image
        uses: docker/build-push-action@v6
        with:
          context: .
          load: true
          tags: oximg:ci
          cache-from: type=gha,scope=${{ matrix.arch }}
          cache-to: type=gha,scope=${{ matrix.arch }},mode=max

      - name: Smoke test the served pipeline
        run: |
          set -euo pipefail
          mkdir -p /tmp/img && cp tests/fixtures/photo.jpg tests/fixtures/rgb.png \
            tests/fixtures/photo.webp tests/fixtures/photo.avif tests/fixtures/alpha.avif /tmp/img/
          # No --rm on ox: its exit code is inspected by the graceful-stop
          # check at the end.
          docker run -d --name ox -v /tmp/img:/images:ro -p 8081:8081 \
            -e OXIMG_OPTIONS_PREFIX=/image oximg:ci
          for i in $(seq 1 100); do curl -sf http://127.0.0.1:8081/health >/dev/null && break; sleep 0.3; done

          fetch() { # file expected-content-type
            local got
            got=$(curl -sf -o "/tmp/out.$1" -w '%{content_type}' "http://127.0.0.1:8081/resize/100/100/$1")
            [ "$got" = "$2" ] || { echo "FAIL $1: content-type $got != $2"; exit 1; }
            [ "$(stat -c%s "/tmp/out.$1")" -gt 200 ] || { echo "FAIL $1: output too small"; exit 1; }
            echo "ok: $1 -> $2 ($(stat -c%s "/tmp/out.$1") bytes)"
          }
          fetch photo.jpg image/jpeg
          head -c2 /tmp/out.photo.jpg | od -An -tx1 | grep -q "ff d8"
          fetch rgb.png image/png
          head -c8 /tmp/out.rgb.png | grep -q PNG
          fetch photo.webp image/webp
          head -c16 /tmp/out.photo.webp | grep -q WEBP
          fetch photo.avif image/avif
          head -c12 /tmp/out.photo.avif | grep -q ftypavif
          fetch alpha.avif image/avif
          head -c12 /tmp/out.alpha.avif | grep -q ftypavif
          echo "ok: magic bytes for all five outputs"

          # Round-trip: the AVIF the server just encoded must decode through
          # the server's own pipeline.
          mkdir -p /tmp/img-rt && cp /tmp/out.photo.avif /tmp/img-rt/rt.avif
          docker run -d --rm --name ox2 -v /tmp/img-rt:/images:ro -p 8082:8081 oximg:ci
          for i in $(seq 1 100); do curl -sf http://127.0.0.1:8082/health >/dev/null && break; sleep 0.3; done
          curl -sf -o /tmp/rt.avif "http://127.0.0.1:8082/resize/50/50/rt.avif"
          head -c12 /tmp/rt.avif | grep -q ftypavif
          echo "ok: served AVIF round-trips through the decoder"

          # Error mapping stays intact (0/0 is the no-box error; a
          # single zero axis is the width-/height-only grammar).
          [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/resize/100/100/missing.jpg)" = 404 ]
          [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/resize/0/0/photo.jpg)" = 400 ]
          [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/resize/100/0/photo.jpg)" = 200 ]
          echo "ok: error mapping"

          # Options route (OXIMG_OPTIONS_PREFIX): the Cloudflare Images
          # grammar works in the image, and unknown options are named
          # 400s rather than silently ignored.
          [ "$(curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:8081/image/width=100,quality=80/photo.jpg')" = 200 ]
          [ "$(curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:8081/image/width=100,fit=cover/photo.jpg')" = 400 ]
          echo "ok: options route"

          # Graceful shutdown through the container path: docker stop
          # sends SIGTERM to PID 1 (exec-form CMD, unprivileged user) and
          # the server must exit 0 well inside the 10s grace period — a
          # SIGKILL fallback shows up as exit 137 and ~10s elapsed. This
          # verifies signal delivery in the image, which cargo tests
          # cannot.
          start=$(date +%s)
          docker stop ox >/dev/null
          elapsed=$(( $(date +%s) - start ))
          code=$(docker inspect ox --format '{{.State.ExitCode}}')
          [ "$code" = 0 ] || { echo "FAIL graceful stop: exit code $code"; exit 1; }
          [ "$elapsed" -lt 8 ] || { echo "FAIL graceful stop: took ${elapsed}s (SIGKILL fallback?)"; exit 1; }
          echo "ok: graceful stop (exit 0 in ${elapsed}s)"
          docker rm ox >/dev/null

          docker stop ox2 >/dev/null

      - name: Log in to GHCR
        if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Log in to Docker Hub
        if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Push by digest
        if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
        id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          outputs: type=image,"name=ghcr.io/oximg/oximg,docker.io/oximg/oximg",push-by-digest=true,name-canonical=true,push=true
          cache-from: type=gha,scope=${{ matrix.arch }}

      - name: Export digest
        if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
        run: |
          mkdir -p /tmp/digests
          digest="${{ steps.push.outputs.digest }}"
          touch "/tmp/digests/${digest#sha256:}"

      - name: Upload digest
        if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
        uses: actions/upload-artifact@v4
        with:
          name: digest-${{ matrix.arch }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1

  publish:
    if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
    needs: image
    runs-on: ubuntu-24.04
    permissions:
      packages: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          path: /tmp/digests
          pattern: digest-*
          merge-multiple: true
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Create multi-arch manifests
        working-directory: /tmp/digests
        env:
          REF_NAME: ${{ github.ref_name }}
          REF_TYPE: ${{ github.ref_type }}
        run: |
          if [ "$REF_TYPE" = "tag" ]; then TAG="${REF_NAME#v}"; else TAG=latest; fi
          for repo in ghcr.io/oximg/oximg docker.io/oximg/oximg; do
            docker buildx imagetools create -t "$repo:$TAG" \
              $(printf "$repo@sha256:%s " *)
            docker buildx imagetools inspect "$repo:$TAG"
          done