name: Publish to Docker Hub on: push: branches: - main tags: - "v*" paths-ignore: - ".github/workflows/**" # Use 'released' instead of 'published' so editing/re-publishing old releases # does NOT re-trigger this workflow. 'released' fires only on the initial # release publication (and pre-release → release transition). release: types: [released] workflow_dispatch: inputs: version: description: "Version tag to build (e.g. 3.8.4)" required: true type: string promote_latest: description: "Also tag :latest (only if this is the highest semver)" required: false type: boolean default: false permissions: contents: read packages: write jobs: prepare: name: Resolve Docker release metadata runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} promote_latest: ${{ steps.version.outputs.promote_latest }} skip: ${{ steps.version.outputs.skip }} env: IMAGE_NAME: diegosouzapw/omniroute steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }} # Need full tag history for semver comparison when deciding :latest. fetch-depth: 0 - name: Resolve version, latest-promotion, and skip flag id: version env: EVENT_NAME: ${{ github.event_name }} REF_NAME: ${{ github.ref_name }} REF_TYPE: ${{ github.ref_type }} INPUT_VERSION: ${{ inputs.version }} PROMOTE_INPUT: ${{ inputs.promote_latest }} run: | set -euo pipefail # 1) Resolve version string from the trigger (all inputs come via env). case "$EVENT_NAME" in workflow_dispatch) VERSION="${INPUT_VERSION#v}" ;; push) if [ "$REF_TYPE" = "tag" ]; then VERSION="${REF_NAME#v}" else # Push to main → build & tag as `main` only. Never touch :latest. VERSION="main" fi ;; release) VERSION="${REF_NAME#v}" ;; *) VERSION="${REF_NAME#v}" ;; esac # Sanity-check: only allow [A-Za-z0-9._-] in VERSION (defense in depth). if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then echo "Refusing to use unsafe VERSION value: $VERSION" >&2 exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" # 2) Decide whether to promote :latest. PROMOTE="false" if [ "$VERSION" = "main" ]; then PROMOTE="false" elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then echo "Pre-release identifier detected — skipping :latest." PROMOTE="false" elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then PROMOTE="${PROMOTE_INPUT:-false}" else git fetch --tags --quiet || true HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "") if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then PROMOTE="true" else echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-}). Not promoting :latest." fi fi echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT" # 3) Skip if this exact version is already published in Docker Hub. # `main` is always rebuilt (mutable floating tag). SKIP="false" if [ "$VERSION" != "main" ]; then if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild." SKIP="true" fi fi echo "skip=$SKIP" >> "$GITHUB_OUTPUT" echo "Publishing diegosouzapw/omniroute:$VERSION (promote_latest=$PROMOTE, skip=$SKIP)" build: name: Build Docker (${{ matrix.platform }}) needs: prepare if: needs.prepare.outputs.skip != 'true' runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: - platform: linux/amd64 runner: ubuntu-24.04 arch: amd64 - platform: linux/arm64 runner: ubuntu-24.04-arm arch: arm64 env: IMAGE_NAME: diegosouzapw/omniroute GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }} fetch-depth: 0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push platform image by digest id: build uses: docker/build-push-action@v7 with: context: . target: runner-base platforms: ${{ matrix.platform }} outputs: type=image,push-by-digest=true,name-canonical=true,push=true tags: | ${{ env.IMAGE_NAME }} ${{ env.GHCR_IMAGE_NAME }} cache-from: type=gha,scope=docker-${{ matrix.arch }} cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max no-cache: false env: DOCKER_BUILDKIT_INLINE_CACHE: 1 - name: Export digest env: DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail mkdir -p /tmp/digests digest="${DIGEST#sha256:}" touch "/tmp/digests/${digest}" - name: Upload digest uses: actions/upload-artifact@v7 with: name: digests-${{ matrix.arch }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 merge: name: Publish multi-arch manifests needs: - prepare - build if: needs.prepare.outputs.skip != 'true' runs-on: ubuntu-latest env: IMAGE_NAME: diegosouzapw/omniroute GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute VERSION: ${{ needs.prepare.outputs.version }} PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }} steps: - name: Checkout uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }} fetch-depth: 0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Download digests uses: actions/download-artifact@v8 with: pattern: digests-* path: /tmp/digests merge-multiple: true - name: Create Docker Hub manifest run: | set -euo pipefail tags=(-t "${IMAGE_NAME}:${VERSION}") if [ "$PROMOTE_LATEST" = "true" ]; then tags+=(-t "${IMAGE_NAME}:latest") fi refs=() while IFS= read -r digest_file; do refs+=("${IMAGE_NAME}@sha256:$(basename "$digest_file")") done < <(find /tmp/digests -type f | sort) if [ "${#refs[@]}" -eq 0 ]; then echo "No image digests were downloaded." >&2 exit 1 fi docker buildx imagetools create "${tags[@]}" "${refs[@]}" - name: Create GHCR manifest run: | set -euo pipefail tags=(-t "${GHCR_IMAGE_NAME}:${VERSION}") if [ "$PROMOTE_LATEST" = "true" ]; then tags+=(-t "${GHCR_IMAGE_NAME}:latest") fi refs=() while IFS= read -r digest_file; do refs+=("${GHCR_IMAGE_NAME}@sha256:$(basename "$digest_file")") done < <(find /tmp/digests -type f | sort) if [ "${#refs[@]}" -eq 0 ]; then echo "No image digests were downloaded." >&2 exit 1 fi docker buildx imagetools create "${tags[@]}" "${refs[@]}" - name: Inspect image if: needs.prepare.outputs.version != 'main' run: | docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}" - name: Update Docker Hub description # Only refresh README/description when we actually promote :latest # (avoids overwriting from main pushes or back-fill builds). if: needs.prepare.outputs.promote_latest == 'true' uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} repository: diegosouzapw/omniroute short-description: "OmniRoute — Unified AI proxy. Route any LLM through one endpoint." readme-filepath: ./README.md