name: Container Images # Builds the multi-arch (linux/amd64 + linux/arm64) gateway image and # publishes it to GHCR so home-server/NAS users can `docker pull` a release # instead of building from a source checkout. See docs/docker.md. # # Publishing model: # * Pushing a release tag (v0.5.0rc3, v0.6.0, ...) first publishes the # immutable ghcr.io//: image. The workflow verifies its # amd64/arm64 manifest and container HEALTHCHECK before moving :latest. # :latest tracks the most recently pushed release tag — including # prereleases and backports; if a backport repoints it, re-run this # workflow from the newest tag. # * workflow_dispatch validates the full multi-arch build without # publishing; set publish=true to push the result as :edge. # # The GITHUB_TOKEN with job-level `packages: write` is the only credential. # First-time note for maintainers: a new GHCR package is created private — # flip ghcr.io// to public in the package settings once. on: push: tags: - "v*" workflow_dispatch: inputs: publish: description: Push the built image to GHCR as the edge tag (leave off to only validate the build) type: boolean required: false default: false permissions: contents: read concurrency: group: container-images-${{ github.ref }} cancel-in-progress: false jobs: build-and-publish: name: Build multi-arch gateway image runs-on: ubuntu-latest # The arm64 leg runs under QEMU emulation. All compiled dependencies # install from prebuilt aarch64 wheels, so this is emulated pip time, # not source builds — slow but bounded. timeout-minutes: 120 permissions: contents: read packages: write steps: - name: Validate release tag if: github.event_name == 'push' run: | if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9]+[.][0-9]+[.][0-9]+.*$ ]]; then echo "Release tag must look like v0.2.0rc1 or v0.2.0: ${GITHUB_REF_NAME}" >&2 exit 1 fi - name: Checkout uses: actions/checkout@v4 with: lfs: true persist-credentials: false - name: Check tag version if: github.event_name == 'push' run: | version="$(python3 - <<'PY' import tomllib from pathlib import Path project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) print(project["project"]["version"]) PY )" tag_version="${GITHUB_REF_NAME#v}" if [[ "${tag_version}" != "${version}" ]]; then echo "Tag ${GITHUB_REF_NAME} does not match project version ${version}" >&2 exit 1 fi - name: Hydrate router assets run: git lfs pull --include="src/opensquilla/squilla_router/models/**" # The Dockerfile hard-fails on LFS pointer files; failing here first # gives a clearer error than a mid-build SystemExit. - name: Verify router assets are hydrated run: | python3 - <<'PY' from pathlib import Path root = Path("src/opensquilla/squilla_router/models/v4.2_phase3_inference") required = [ root / "PROVENANCE.md", root / "artifact_manifest.json", root / "bge_onnx" / "model.onnx", root / "features" / "tfidf.pkl", root / "lgbm_main.bin", root / "mlp" / "model.onnx", root / "router.runtime.yaml", ] for path in required: assert path.is_file(), f"missing router asset: {path}" prefix = path.read_bytes()[:80] assert not prefix.startswith(b"version https://git-lfs.github.com/spec/v1"), ( f"Git LFS pointer file is not hydrated: {path}" ) PY - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR if: ${{ github.event_name == 'push' || inputs.publish == true }} uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # Release tags are PEP 440 (v0.5.0rc3), not strict semver, so the git # tag is mapped through as-is instead of via type=semver. - name: Compute image metadata id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | type=ref,event=tag,enable=${{ github.event_name == 'push' }} type=raw,value=edge,enable=${{ github.event_name == 'workflow_dispatch' }} flavor: | latest=false - name: Build multi-arch image uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 push: ${{ github.event_name == 'push' || inputs.publish == true }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} # Keep the pushed artifact a plain two-entry manifest list; the # default provenance attestation adds an unknown/unknown entry that # confuses registry UIs and `docker pull` inspection. provenance: false - name: Select pushed image if: ${{ github.event_name == 'push' || inputs.publish == true }} id: pushed_image shell: bash run: | if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then image_tag="${GITHUB_REF_NAME}" else image_tag="edge" fi echo "ref=ghcr.io/${GITHUB_REPOSITORY}:${image_tag}" >> "${GITHUB_OUTPUT}" - name: Verify pushed manifest platforms if: ${{ github.event_name == 'push' || inputs.publish == true }} env: IMAGE_REF: ${{ steps.pushed_image.outputs.ref }} run: | python3 - <<'PY' import json import os import subprocess image_ref = os.environ["IMAGE_REF"] manifest = json.loads( subprocess.check_output( ["docker", "buildx", "imagetools", "inspect", image_ref, "--raw"], text=True, ) ) platforms = { f'{item["platform"]["os"]}/{item["platform"]["architecture"]}' for item in manifest.get("manifests", []) } expected = {"linux/amd64", "linux/arm64"} if platforms != expected: raise SystemExit( f"{image_ref} has platforms {sorted(platforms)}; " f"expected {sorted(expected)}" ) print(f"Verified {image_ref}: {sorted(platforms)}") PY - name: Smoke pushed image HEALTHCHECK if: ${{ github.event_name == 'push' || inputs.publish == true }} env: IMAGE_REF: ${{ steps.pushed_image.outputs.ref }} shell: bash run: | set -euo pipefail container_id="$(docker run --detach --pull=always "${IMAGE_REF}")" trap 'docker rm --force "${container_id}" >/dev/null 2>&1 || true' EXIT deadline=$((SECONDS + 180)) while (( SECONDS < deadline )); do running="$(docker inspect --format '{{.State.Running}}' "${container_id}")" health="$(docker inspect \ --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' \ "${container_id}")" echo "Container health: ${health}" if [[ "${health}" == "healthy" ]]; then exit 0 fi if [[ "${running}" != "true" || "${health}" == "unhealthy" || "${health}" == "missing" ]]; then docker logs "${container_id}" exit 1 fi sleep 5 done echo "Container did not become healthy within 180 seconds" >&2 docker logs "${container_id}" exit 1 # Promotion happens only after the immutable release image passes both # remote-manifest verification and its image-defined HEALTHCHECK. - name: Promote verified release image to latest if: github.event_name == 'push' env: IMAGE_REF: ${{ steps.pushed_image.outputs.ref }} LATEST_REF: ghcr.io/${{ github.repository }}:latest run: | docker buildx imagetools create \ --tag "${LATEST_REF}" \ "${IMAGE_REF}"