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 # Least-privilege default: read-only at the top level; the build and merge jobs that # push to GHCR grant packages: write themselves (Scorecard TokenPermissions). permissions: contents: read 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@v7 with: persist-credentials: false 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 # Decide via the extracted helper, which folds VERSION into the # candidate set so the result is independent of git-tag sync timing # on `release` events (#5301). Without that, the freshly-created tag # is often not yet visible here and :latest stays a release behind. PROMOTE=$(git tag -l 'v[0-9]*' | bash scripts/ci/should-promote-latest.sh "$VERSION") if [ "$PROMOTE" != "true" ]; then echo "Version $VERSION is not the highest stable semver. 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 }} permissions: contents: read packages: write 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@v7 with: persist-credentials: false 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: Build and push WEB platform image by digest id: build-web uses: docker/build-push-action@v7 with: context: . target: runner-web 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-web-${{ matrix.arch }} cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max no-cache: false env: DOCKER_BUILDKIT_INLINE_CACHE: 1 - name: Export digests env: DIGEST_BASE: ${{ steps.build.outputs.digest }} DIGEST_WEB: ${{ steps.build-web.outputs.digest }} run: | set -euo pipefail mkdir -p /tmp/digests/base /tmp/digests/web touch "/tmp/digests/base/${DIGEST_BASE#sha256:}" touch "/tmp/digests/web/${DIGEST_WEB#sha256:}" - name: Upload base digests uses: actions/upload-artifact@v7 with: name: digests-base-${{ matrix.arch }} path: /tmp/digests/base/* if-no-files-found: error retention-days: 1 - name: Upload web digests uses: actions/upload-artifact@v7 with: name: digests-web-${{ matrix.arch }} path: /tmp/digests/web/* 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 permissions: contents: read packages: write security-events: write 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@v7 with: persist-credentials: false 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 base digests uses: actions/download-artifact@v8 with: pattern: digests-base-* path: /tmp/digests/base merge-multiple: true - name: Download web digests uses: actions/download-artifact@v8 with: pattern: digests-web-* path: /tmp/digests/web merge-multiple: true - name: Create Docker Hub manifest run: | set -euo pipefail create_manifest() { local image="$1" suffix="$2" dir="$3" local tags=(-t "${image}:${VERSION}${suffix}") if [ "$PROMOTE_LATEST" = "true" ]; then tags+=(-t "${image}:latest${suffix}") fi local refs=() while IFS= read -r digest_file; do refs+=("${image}@sha256:$(basename "$digest_file")") done < <(find "$dir" -type f | sort) if [ "${#refs[@]}" -eq 0 ]; then echo "No image digests in $dir" >&2 exit 1 fi docker buildx imagetools create "${tags[@]}" "${refs[@]}" } create_manifest "${IMAGE_NAME}" "" /tmp/digests/base create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web - name: Create GHCR manifest run: | set -euo pipefail create_manifest() { local image="$1" suffix="$2" dir="$3" local tags=(-t "${image}:${VERSION}${suffix}") if [ "$PROMOTE_LATEST" = "true" ]; then tags+=(-t "${image}:latest${suffix}") fi local refs=() while IFS= read -r digest_file; do refs+=("${image}@sha256:$(basename "$digest_file")") done < <(find "$dir" -type f | sort) if [ "${#refs[@]}" -eq 0 ]; then echo "No image digests in $dir" >&2 exit 1 fi docker buildx imagetools create "${tags[@]}" "${refs[@]}" } create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web - name: Inspect image if: needs.prepare.outputs.version != 'main' run: | docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}" - name: Generate CycloneDX SBOM (image, advisory) if: needs.prepare.outputs.version != 'main' continue-on-error: true uses: anchore/sbom-action@v0 with: image: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }} format: cyclonedx-json output-file: sbom-image.cdx.json artifact-name: sbom-image.cdx.json # Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but # never blocks (exit-code 0). The blocking gate below narrows to CRITICAL. # # ignore-unfixed mirrors the blocking gate: the Security tab must surface only # ACTIONABLE vulnerabilities — ones with a published fix we can pull by rebuilding # on a patched base or bumping the dep. Without it the advisory upload floods the # tab with unfixable base-image OS CVEs (Debian trixie packages with no upstream # patch yet, overwhelmingly local-only and not reachable from the proxy request # surface), which is noise an operator cannot act on. trivyignores points at the # repo-root .trivyignore so accepted-risk fixable CVEs have one auditable home. # See docs/security/SUPPLY_CHAIN.md. - name: Trivy image scan (SARIF, advisory) if: needs.prepare.outputs.version != 'main' continue-on-error: true uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }} format: sarif output: trivy-results.sarif severity: HIGH,CRITICAL ignore-unfixed: true trivyignores: .trivyignore exit-code: "0" # BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the # published image. Narrowed to severity CRITICAL (HIGH stays visible in the # SARIF step above, not blocking). ignore-unfixed:true so an unfixable base-image # CVE with no upstream patch does not red the release (reduces false-blocks); # a fixable CRITICAL still blocks. Per docs/security/SUPPLY_CHAIN.md. NB: Trivy # scans against a CVE DB that grows continuously — a newly-disclosed CRITICAL on # an unchanged base image can red this gate; the fix is to rebuild on a patched # base, bump the dep, or add a justified .trivyignore entry (see the CVE-variance # note in docs/security/SUPPLY_CHAIN.md). - name: Trivy CRITICAL gate (blocking) if: needs.prepare.outputs.version != 'main' uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.GHCR_IMAGE_NAME }}:${{ env.VERSION }} format: table severity: CRITICAL ignore-unfixed: true exit-code: "1" - name: Upload Trivy SARIF to Security tab if: needs.prepare.outputs.version != 'main' continue-on-error: true uses: github/codeql-action/upload-sarif@v4 with: sarif_file: trivy-results.sarif category: trivy-image - 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