Spaces:
Runtime error
Runtime error
| 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 | |
| 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:-<none>}). 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 | |