name: Docker on: push: branches: [main] tags: ['v*.*.*'] paths-ignore: - '**/*.md' - 'docs/**' - 'demo/**' - '.gitignore' - 'LICENSE' pull_request: paths-ignore: - '**/*.md' - 'docs/**' - 'demo/**' - '.gitignore' - 'LICENSE' workflow_dispatch: env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} concurrency: group: docker-${{ github.ref }} cancel-in-progress: true jobs: build: name: build & push image runs-on: ubuntu-latest permissions: contents: read packages: write # Required for cosign keyless signing of pushed images. id-token: write steps: - uses: actions/checkout@v4 - name: Set up QEMU # Only needed to cross-build arm64, which only tagged releases do. if: startsWith(github.ref, 'refs/tags/') uses: docker/setup-qemu-action@v3 - name: Set up Buildx uses: docker/setup-buildx-action@v3 # A pull request from a fork cannot read the registry secret, so builds are # verified without pushing. Only trusted refs publish. - name: Log in to GHCR if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Derive tags and labels id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} labels: | org.opencontainers.image.title=Freeway org.opencontainers.image.description=Self-hosted OpenAI-compatible gateway for free-tier LLM providers - name: Build and push id: build uses: docker/build-push-action@v6 with: context: . # Multi-arch on releases; native-only otherwise, since emulating # arm64 is most of the build time and nothing consumes it per-commit. platforms: ${{ startsWith(github.ref, 'refs/tags/') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max provenance: true sbom: true # Smoke-test the image we just built, on the native arch only — an arm64 # image cannot be started on an amd64 runner without emulation overhead. - name: Smoke-test the image if: github.event_name != 'pull_request' run: | set -euo pipefail IMAGE="$(echo '${{ steps.meta.outputs.tags }}' | head -n1)" # The gateway refuses to bind a public interface with no virtual keys — # a deliberate failsafe, and the container binds 0.0.0.0. CI has to # supply one, exactly as a real deployment would. docker run -d --name freeway-smoke -p 8787:8787 --platform linux/amd64 \ -e FREEWAY_KEYS=fw-ci-smoke-only \ -e FREEWAY_ADMIN_KEY=fw-ci-admin-only \ "$IMAGE" for i in $(seq 1 30); do if curl -fsS http://127.0.0.1:8787/healthz >/dev/null 2>&1; then echo "gateway is healthy" curl -fsS http://127.0.0.1:8787/healthz; echo # An unauthenticated call must be refused, and an app key must not # reach the admin surface. If either regresses, the image is unsafe # to publish however green everything else looks. code=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:8787/v1/chat/completions \ -H 'content-type: application/json' -d '{"model":"auto","messages":[]}') [ "$code" = "401" ] || { echo "expected 401 without a key, got $code" >&2; docker rm -f freeway-smoke; exit 1; } code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8787/api/logs \ -H 'authorization: Bearer fw-ci-smoke-only') [ "$code" = "403" ] || { echo "expected 403 for an inference key on /api, got $code" >&2; docker rm -f freeway-smoke; exit 1; } echo "auth boundaries hold" docker rm -f freeway-smoke exit 0 fi sleep 1 done echo "gateway did not become healthy in 30s" >&2 docker logs freeway-smoke >&2 docker rm -f freeway-smoke exit 1 - name: Summary if: github.event_name != 'pull_request' run: | { echo "### Image published" echo echo '```' echo '${{ steps.meta.outputs.tags }}' echo '```' echo echo "digest: \`${{ steps.build.outputs.digest }}\`" } >> "$GITHUB_STEP_SUMMARY"