oximg 0.11.0

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

on:
  push:
    tags: ["v*"]
  # npm-only re-publish (e.g. after bumping npm/package.json); the
  # crates job is tag-gated.
  workflow_dispatch:

jobs:
  # Gate every publish on a green build+test of the tagged commit.
  # Default features (server, no avif) so this needs no system codec
  # libraries and stays fast; the avif matrix already gated main CI on
  # the same commit before the tag was pushed.
  test:
    if: github.ref_type == 'tag'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - name: Install build dependencies
        run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake nasm
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Tag matches Cargo.toml version
        run: |
          tag="${GITHUB_REF_NAME#v}"
          crate=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2)
          test "$tag" = "$crate" || { echo "::error::tag $tag != Cargo.toml $crate"; exit 1; }
      - name: Test
        run: cargo test --release

  publish:
    needs: test
    if: github.ref_type == 'tag'
    runs-on: ubuntu-latest
    environment: release
    permissions:
      id-token: write # OIDC token exchange for crates.io Trusted Publishing
      contents: read
    steps:
      - uses: actions/checkout@v5
      - name: Install build dependencies
        run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake nasm
      - uses: dtolnay/rust-toolchain@stable
      - uses: rust-lang/crates-io-auth-action@v1
        id: auth
      - name: Publish to crates.io
        run: cargo publish
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}

  npm:
    needs: test
    runs-on: ubuntu-latest
    environment: release
    permissions:
      id-token: write # OIDC token exchange for npm Trusted Publishing
      contents: read
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with:
          node-version: 24 # bundles npm >= 11.5.1, required for OIDC publishes
          registry-url: https://registry.npmjs.org
      - name: Publish @oximg/oximg
        working-directory: npm
        run: npm publish --access public

  # Create the GitHub Release from the CHANGELOG section for this tag.
  # (The Homebrew tap is still bumped by hand — it lives in another repo
  # and the local build-from-source check is a useful release gate.)
  github-release:
    needs: test
    if: github.ref_type == 'tag'
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v5
      - name: Extract CHANGELOG section
        run: |
          ver="${GITHUB_REF_NAME#v}"
          awk -v v="$ver" '
            $0 ~ ("^## \\[" v "\\]") { grab=1; next }
            grab && /^## \[/ { exit }
            grab { print }
          ' CHANGELOG.md > notes.md
          if [ ! -s notes.md ]; then
            echo "See [CHANGELOG.md](https://github.com/oximg/oximg/blob/main/CHANGELOG.md)." > notes.md
          fi
      - name: Create release
        env:
          GH_TOKEN: ${{ github.token }}
        run: gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --title "oximg ${GITHUB_REF_NAME#v}" --notes-file notes.md

  # Prebuilt binaries for the CLI/CI-pipeline persona: built on native
  # runners with the recipe main CI already proves on every push,
  # smoke-tested as artifacts, then attached to the release with
  # checksums. Default features (server + CLI, no AVIF) to match
  # crates.io and Homebrew; the Docker images stay the AVIF-included
  # channel. These are the glibc builds (Ubuntu 24.04, so glibc >=
  # 2.39 — fine for the CI-runner audience); musl has its own job
  # below.
  binaries:
    needs: github-release
    if: github.ref_type == 'tag'
    strategy:
      matrix:
        include:
          - runner: ubuntu-24.04
            target: x86_64-unknown-linux-gnu
          - runner: ubuntu-24.04-arm
            target: aarch64-unknown-linux-gnu
          - runner: macos-15
            target: aarch64-apple-darwin
    runs-on: ${{ matrix.runner }}
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v5
      - name: Install build dependencies (Linux)
        if: runner.os == 'Linux'
        run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake nasm
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: Build
        run: cargo build --release --locked
        env:
          # Static libstdc++/libgcc so the only dynamic dependency left
          # is glibc (jpegli is C++; everything else already links
          # statically). No effect asked of macOS, which links the
          # system libc++ like every macOS binary.
          RUSTFLAGS: ${{ runner.os == 'Linux' && '-C link-arg=-static-libstdc++ -C link-arg=-static-libgcc' || '' }}
      - name: Smoke test the artifact
        run: |
          set -euo pipefail
          bin=target/release/oximg
          "$bin" --version
          "$bin" resize tests/fixtures/photo.jpg 100 100 /tmp/smoke.webp
          "$bin" probe /tmp/smoke.webp | grep -q image/webp
          echo "ok: resize + probe round-trip"
      - name: Package and upload
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          name="oximg-${GITHUB_REF_NAME}-${{ matrix.target }}"
          if [ "${{ runner.os }}" = "macOS" ]; then
            strip -Sx target/release/oximg
          else
            strip target/release/oximg
          fi
          mkdir "$name"
          cp target/release/oximg LICENSE README.md THIRD-PARTY-LICENSES.md "$name/"
          tar czf "$name.tar.gz" "$name"
          shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256"
          gh release upload "$GITHUB_REF_NAME" "$name.tar.gz" "$name.tar.gz.sha256" \
            --repo "$GITHUB_REPOSITORY" --clobber

  # musl builds, for Alpine — the base image most Ruby containers use,
  # and where a glibc binary simply does not run. The build is native
  # inside an Alpine container rather than a cross-compile, because the
  # hard part was never Rust: jpegli is C++, and pointing a
  # cross-toolchain at it is a different job from letting Alpine's own
  # gcc build it. `docker run` rather than a job-level `container:`,
  # since actions/checkout ships a glibc node and cannot run on Alpine.
  #
  # The output is static-pie linked — no interpreter, no libc to match —
  # so unlike the gnu builds it carries no minimum-glibc caveat.
  binaries-musl:
    needs: github-release
    if: github.ref_type == 'tag'
    strategy:
      matrix:
        include:
          - runner: ubuntu-24.04
            target: x86_64-unknown-linux-musl
          - runner: ubuntu-24.04-arm
            target: aarch64-unknown-linux-musl
    runs-on: ${{ matrix.runner }}
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v5
      # rust:alpine is multi-arch, so each runner pulls its own
      # architecture and builds natively — no QEMU in the loop.
      - name: Build in Alpine
        run: |
          docker run --rm -v "$PWD":/src -w /src rust:alpine sh -c '
            apk add --no-cache build-base cmake nasm >/dev/null &&
            cargo build --release --locked &&
            strip target/release/oximg'
          # The container builds as root into the mounted workspace, so
          # every artifact it leaves behind is root-owned. Hand the
          # whole tree back before any host-side step touches it.
          sudo chown -R "$(id -u):$(id -g)" target
      - name: Smoke test the artifact
        run: |
          set -euo pipefail
          docker run --rm -v "$PWD":/src -w /src alpine:latest sh -c '
            set -e
            ./target/release/oximg --version
            ./target/release/oximg resize tests/fixtures/photo.jpg 100 100 /tmp/smoke.webp
            ./target/release/oximg probe /tmp/smoke.webp | grep -q image/webp'
          echo "ok: runs on bare Alpine, no runtime dependencies"
      - name: Package and upload
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          name="oximg-${GITHUB_REF_NAME}-${{ matrix.target }}"
          mkdir "$name"
          cp target/release/oximg LICENSE README.md THIRD-PARTY-LICENSES.md "$name/"
          tar czf "$name.tar.gz" "$name"
          shasum -a 256 "$name.tar.gz" > "$name.tar.gz.sha256"
          gh release upload "$GITHUB_REF_NAME" "$name.tar.gz" "$name.tar.gz.sha256" \
            --repo "$GITHUB_REPOSITORY" --clobber

  # The `oximg` gem, published as one plain-Ruby gem plus a platform gem
  # per released binary. The platform gems bundle the executable the
  # `binaries` jobs just uploaded, which is the whole point: `bundle
  # install` and a Rails app has image processing, with no libvips or
  # ImageMagick on the box. Platforms with no build (Intel macOS) fall
  # back to the plain gem, which resolves a binary from OXIMG_BIN or
  # PATH.
  #
  # 0.10.1 was pushed by hand, so both gem names already exist: what this
  # job needs is a trusted publisher on each gem's settings page
  # (repository, this workflow file, the `release` environment). Without
  # it the OIDC exchange has nothing to match and the push 403s.
  rubygem:
    needs: [binaries, binaries-musl]
    if: github.ref_type == 'tag'
    runs-on: ubuntu-latest
    environment: release
    permissions:
      id-token: write # OIDC token exchange for RubyGems Trusted Publishing
      contents: read # gh release download
    steps:
      - uses: actions/checkout@v5
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.4"
      - name: Tag matches the gem version
        run: |
          tag="${GITHUB_REF_NAME#v}"
          gem_version=$(grep -m1 'VERSION = ' rubygem/oximg/lib/oximg/version.rb | cut -d'"' -f2)
          test "$tag" = "$gem_version" || { echo "::error::tag $tag != gem $gem_version"; exit 1; }
      # The suite CI runs against a real binary; here it runs without
      # one (the integration cases skip themselves), which is still
      # enough to catch a gem that cannot even load.
      - name: Test
        working-directory: rubygem/oximg
        run: |
          bundle install --jobs 4
          bundle exec rake test
      - name: Download the release binaries
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          set -euo pipefail
          mkdir -p dist
          gh release download "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" \
            --pattern 'oximg-*.tar.gz' --dir dist
      - name: Build the gems
        working-directory: rubygem/oximg
        run: |
          set -euo pipefail
          dist="$GITHUB_WORKSPACE/dist"
          mkdir -p exe && rm -f exe/oximg
          # Plain-Ruby gem first, while exe/ is still empty.
          gem build oximg.gemspec -o "$dist/oximg-ruby.gem"
          # The Linux platforms are spelled out per libc: an
          # unqualified `x86_64-linux` gem installs on musl too, where a
          # glibc binary cannot run. RubyGems reports `x86_64-linux-musl`
          # as the local platform on Alpine, so these are the names
          # bundler matches against.
          for pair in x86_64-unknown-linux-gnu:x86_64-linux-gnu \
                      aarch64-unknown-linux-gnu:aarch64-linux-gnu \
                      x86_64-unknown-linux-musl:x86_64-linux-musl \
                      aarch64-unknown-linux-musl:aarch64-linux-musl \
                      aarch64-apple-darwin:arm64-darwin; do
            target="${pair%%:*}"
            platform="${pair##*:}"
            name="oximg-${GITHUB_REF_NAME}-${target}"
            tar xzf "$dist/$name.tar.gz" -C "$dist"
            install -m 0755 "$dist/$name/oximg" exe/oximg
            OXIMG_GEM_PLATFORM="$platform" gem build oximg.gemspec -o "$dist/oximg-$platform.gem"
          done
          rm -f exe/oximg
      - uses: rubygems/configure-rubygems-credentials@v2.1.0
      - name: Publish
        run: |
          set -euo pipefail
          for gem in "$GITHUB_WORKSPACE"/dist/oximg-*.gem; do
            gem push "$gem"
          done

  # Separate job, not a second push in the one above: a trusted
  # publisher's credentials are scoped to the gem they were configured
  # for, so each gem name exchanges its own OIDC token. Ordered after
  # `oximg` because oximg-rails depends on it.
  rubygem-rails:
    needs: rubygem
    if: github.ref_type == 'tag'
    runs-on: ubuntu-latest
    environment: release
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v5
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.4"
      - name: Tag matches the gem version
        run: |
          tag="${GITHUB_REF_NAME#v}"
          gem_version=$(grep -m1 'VERSION = ' rubygem/oximg-rails/lib/oximg/rails/version.rb | cut -d'"' -f2)
          test "$tag" = "$gem_version" || { echo "::error::tag $tag != gem $gem_version"; exit 1; }
      - name: Test
        working-directory: rubygem/oximg-rails
        run: |
          bundle install --jobs 4
          bundle exec rake test
      - name: Build
        working-directory: rubygem/oximg-rails
        run: gem build oximg-rails.gemspec -o oximg-rails.gem
      - uses: rubygems/configure-rubygems-credentials@v2.1.0
      - name: Publish
        run: gem push rubygem/oximg-rails/oximg-rails.gem