name: Build runner image + roll out

# Builds the Covalence runner image (docs/COVALENCE_RUNNER.md §2, §9; docs/NAROS.md N3 —
# naros-agent + nucleicd) and pushes it to Cloudflare's OWN managed registry
# (registry.cloudflare.com/<account-id>/nucleic-runner) — the image the `containers` block in
# cloud/nucleic-runner/wrangler.jsonc references (§0.2 item 3b). NOT GHCR: Wrangler / Cloudflare
# Containers can't pull a container image from GHCR without an extra `wrangler containers
# registries configure` + stored pull credentials; the managed registry needs none of that
# (Cloudflare pulls it directly at deploy). `wrangler containers push` authenticates the push
# with CLOUDFLARE_API_TOKEN.
#
# Three stages, mirroring the documented local recipe (containers/nucleic-runner/README.md):
#   1. Cross-compile the fully-static linux/amd64 `nucleicd` with Swift's Static Linux SDK
#      inside the pinned swift toolchain container (scripts/build-nucleicd-linux.sh — installs
#      the SDK with a pinned checksum and gives its sysroot the SQLite that GRDB links).
#   2. Boot-smoke the binary against the deployed relay (must enroll, listen, mint a pairing code).
#   3. `docker build` it onto the naros-agent base (naros.yml publishes the multi-arch index;
#      the amd64 slice is taken here) and `wrangler containers push` it.
#
# Cloudflare Containers run linux/amd64 ONLY, so this image is single-arch on purpose; a
# self-hosted arm64 box runs `nucleicd` natively instead (no container needed).
#
# The naros-agent FROM base still lives on GHCR, but it's pulled only at BUILD time here (by
# Docker, authed with the workflow's GITHUB_TOKEN — permissions: packages: read), never by
# Cloudflare. The runner image Cloudflare pulls is fully self-contained in the managed registry.
#
# Sequencing (NAROS.md N2 caution, inherited by N3): the FROM tag
# `naros-agent:<os/VERSION>` exists only after naros.yml has run green and pushed the
# index. The preflight below fails fast with a pointed message when it hasn't — rerun this
# workflow once naros.yml is green. It also asserts the Dockerfile's NAROS_VERSION default
# equals os/VERSION (the §8 lockstep: the version constant collapses to one file, and the
# forced Dockerfile edit on a bump is what naturally retriggers this build).
#
# Setup: add a CLOUDFLARE_API_TOKEN repo secret (Workers Scripts:Edit — that permission group
# includes Cloudflare Containers image/registry writes — scoped to this account). The account id
# is set as a plain env below (not secret; already committed in the wrangler.jsonc image ref).

on:
  push:
    branches: [main, dev]
    paths:
      - containers/nucleic-runner/**
      - scripts/build-nucleicd-linux.sh
      - .github/workflows/runner-image.yml
      # The image's real payload is the nucleicd binary — a Swift change (nucleicd or the
      # NucleicCore/NucleicProtocol graph it links) that never rebuilds the image ships a
      # silently stale daemon to every runner. Package.* pin the dependency graph.
      - Sources/**
      - Package.swift
      - Package.resolved
  workflow_dispatch:
    inputs:
      tag:
        description: "Image tag to push (the deploy job pins the Worker to this build's digest regardless of tag)"
        required: false
        default: v1

env:
  IMAGE_TAG: v1
  # Must match SWIFT_VERSION in scripts/build-nucleicd-linux.sh (the Static Linux SDK is
  # toolchain-locked; the script fails loudly on a mismatch).
  SWIFT_IMAGE: swift:6.3-bookworm
  # Cloudflare account that owns the managed registry + the nucleic-runner Worker. NOT secret —
  # already committed in cloud/nucleic-runner/wrangler.jsonc's image reference; keep in sync.
  CLOUDFLARE_ACCOUNT_ID: c0b8e2a01e80cd7106e0e98e96f5415f

jobs:
  build-push:
    name: nucleicd cross-build + smoke → runner image → Cloudflare registry
    runs-on: ubuntu-latest  # amd64 — the static cross-build works from any arch, native here
    permissions:
      contents: read
      packages: read   # pull the nucleic-sandbox FROM base from GHCR at build time (below)
    outputs:
      # The manifest digest of the image this run pushed — the deploy job below pins the
      # Worker's containers block to it, which is what actually rolls the new image out.
      image_digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - name: Resolve image reference
        id: ref
        run: |
          # Cloudflare's managed registry, namespaced by account id (registry.cloudflare.com/
          # <account-id>/<image>:<tag>). Must match cloud/nucleic-runner/wrangler.jsonc.
          tag="${{ github.event.inputs.tag || env.IMAGE_TAG }}"
          echo "image=registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}/nucleic-runner" >> "$GITHUB_OUTPUT"
          echo "tag=${tag}" >> "$GITHUB_OUTPUT"

      - name: Log in to GHCR (to pull the naros-agent FROM base only)
        # Read-only: the base image lives on GHCR and may be private. This authenticates the
        # preflight inspect below and the `FROM ghcr.io/…/naros-agent` pull in the docker build.
        # The GITHUB_TOKEN with packages: read can pull same-owner packages. Nothing is pushed
        # to GHCR.
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Preflight the narOS base (lockstep + availability)
        # Fail fast — before the (slow) Swift cross-build — when either half of the narOS
        # version contract is broken:
        #   1. Lockstep (NAROS.md §8): the Dockerfile's NAROS_VERSION default must equal
        #      os/VERSION, so the runner can never silently build on a stale base.
        #   2. Sequencing (NAROS.md N2/N3): the naros-agent tag must already be published
        #      with an amd64 slice — it exists only after naros.yml ran green. When it
        #      isn't, this is an ordering problem, not a defect: rerun after naros.yml.
        run: |
          ver="$(cat os/VERSION)"
          pinned="$(sed -n 's/^ARG NAROS_VERSION=\(.*\)$/\1/p' containers/nucleic-runner/Dockerfile)"
          if [ "$pinned" != "$ver" ]; then
            echo "::error::containers/nucleic-runner/Dockerfile pins NAROS_VERSION=${pinned:-<missing>} but os/VERSION is ${ver} — bump the Dockerfile ARG in lockstep (NAROS.md §8)"
            exit 1
          fi
          owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
          base="ghcr.io/${owner}/naros-agent:${ver}"
          if ! docker manifest inspect "$base" > /tmp/base-manifest.json 2>/tmp/base-manifest.err; then
            cat /tmp/base-manifest.err
            echo "::error::narOS base ${base} is not published — naros.yml must run green (pushing the multi-arch index) before this workflow can build the runner. Rerun once it has (NAROS.md N2 sequencing caution)."
            exit 1
          fi
          if ! grep -q '"architecture": *"amd64"' /tmp/base-manifest.json; then
            echo "::error::narOS base ${base} has no linux/amd64 slice — Cloudflare Containers are amd64-only; check the naros.yml rootfs matrix"
            exit 1
          fi
          echo "base ${base} present with amd64 slice; lockstep OK (${ver})"

      - name: Build static linux/amd64 nucleicd
        run: |
          docker run --rm -v "$PWD":/src -w /src "$SWIFT_IMAGE" \
            scripts/build-nucleicd-linux.sh
          cp .build/x86_64-swift-linux-musl/release/nucleicd containers/nucleic-runner/nucleicd

      - name: Boot smoke test (relay egress probe + live enroll)
        # The §11.1 recipe: the static binary must boot, enroll with the deployed relay, listen,
        # and mint a pairing code before it ships. Native amd64 here — no qemu needed. (This
        # enrolls a throwaway room on the production relay; harmless per COVALENCE_RUNNER
        # §11.1 — hash-derived roomIds can't squat and credentials age out.)
        run: |
          chmod +x containers/nucleic-runner/nucleicd
          file containers/nucleic-runner/nucleicd
          # Independent egress probe: can THIS runner reach the relay's enroll endpoint, and what
          # does it answer? Isolates a runner-network/relay problem from a Swift-binary (static-musl
          # URLSession/libcurl) problem — the daemon's own enroll POST goes through URLSession.
          echo "----- relay egress probe (curl) -----"
          # A well-formed 32-byte base64url static key so a healthy relay returns 200 (a real
          # challenge), not a validation 422. We only care whether we reach the WORKER at all
          # (any HTTP status) vs. a Cloudflare edge bot-challenge ("Just a moment" 403).
          probe_key=$(head -c 32 /dev/zero | base64 -w0 | tr '+/' '-_' | tr -d '=')
          probe_code=$(curl -sS -m 15 -o /tmp/relay.out -w '%{http_code}' \
            -X POST https://relay.nucleic.blakeslee.xyz/v1/relay/enroll/challenge \
            -H 'content-type: application/json' -d "{\"staticKey\":\"$probe_key\"}" || echo 000)
          if grep -qi 'just a moment' /tmp/relay.out; then
            echo "relay BLOCKED by Cloudflare bot challenge (edge, HTTP $probe_code) — enrollment will fail"
          elif [ "$probe_code" = 000 ]; then
            echo "relay UNREACHABLE from this runner (curl failed)"
          else
            echo "relay reachable — Worker responded HTTP $probe_code (200 = healthy; 4xx = reached the Worker); ingress OK"
          fi
          head -c 300 /tmp/relay.out 2>/dev/null; echo
          echo "-------------------------------------"
          # Don't let `set -e` abort before we've printed the log: capture the exit code, then
          # decide. `timeout` returns 124 when it had to kill a still-running daemon (the healthy
          # case — nucleicd runs forever); a clean SIGTERM shutdown returns 0. Any other code is a
          # boot failure whose reason is in the log below.
          set +e
          NUCLEIC_RUNNER_DATA_DIR=/tmp/rd-smoke NUCLEIC_RUNNER_NAME=CI \
            timeout --signal=TERM 25 containers/nucleic-runner/nucleicd --print-pairing \
            > /tmp/smoke.log 2>&1
          code=$?
          set -e
          echo "----- nucleicd exited with $code -----"
          cat /tmp/smoke.log
          echo "--------------------------------------"
          # A Cloudflare edge bot-challenge (HTTP 403 "Just a moment") on the relay from this
          # runner's datacenter IP blocks enrollment before it reaches the Worker — an infra
          # policy, not a binary defect (the identical curl probe above gets the same 403). Don't
          # gate the image on it: warn loudly and publish anyway. Restore the hard live-enroll
          # check by exempting relay.nucleic.blakeslee.xyz from Super Bot Fight Mode / Managed
          # Challenge (the enroll endpoint is already X25519 proof-of-possession protected).
          if grep -qi 'Just a moment' /tmp/smoke.log; then
            echo "::warning::Relay enrollment was blocked by a Cloudflare bot challenge (HTTP 403 'Just a moment') from this runner's IP — NOT a nucleicd defect. Publishing the image without the live-enroll assertion. Exempt the relay host from Cloudflare's bot challenge to re-enable it (and verify production runner enrollment isn't hitting the same 403)."
            exit 0
          fi
          if [ "$code" -ne 124 ] && [ "$code" -ne 0 ]; then
            echo "::error::nucleicd exited early (code $code) on boot — see smoke.log above"
            exit 1
          fi
          grep -q 'listening on: relay' /tmp/smoke.log
          grep -q 'nucleic://pair' /tmp/smoke.log

      - name: Build image (linux/amd64)
        # Native amd64 build on the amd64 runner; loads into the local Docker daemon so
        # `wrangler containers push` can upload it. The static nucleicd binary was copied into the
        # context above. This is the Nucleic-hosted variant: WITH_TAILSCALE defaults to 0 (relay-only
        # reachability, no tailscale) — the self-hosted `WITH_TAILSCALE=1` tag is built out-of-band
        # (see containers/nucleic-runner/README.md "Self-hosted variant").
        run: |
          docker build --platform linux/amd64 \
            --tag "${{ steps.ref.outputs.image }}:${{ steps.ref.outputs.tag }}" \
            --tag "${{ steps.ref.outputs.image }}:latest" \
            --file containers/nucleic-runner/Dockerfile \
            containers/nucleic-runner

      - name: Push to Cloudflare's managed registry
        id: push
        # Uploads the locally-built image to registry.cloudflare.com/<account>/nucleic-runner,
        # authenticating with CLOUDFLARE_API_TOKEN via wrangler's registry credential helper — no
        # `docker login` to Cloudflare and no `wrangler containers registries configure`. Cloudflare
        # then pulls it at deploy with no further config. Pinned to the repo's wrangler version.
        # The manifest digest from the push (`<tag>: digest: sha256:… size: …` in the docker
        # output wrangler streams) is captured as a step output: the deploy job pins the
        # containers block to it, since a re-pushed mutable tag alone never rolls out (below).
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          # Confine the docker auth `wrangler containers push` writes (via its under-the-hood
          # `docker login registry.cloudflare.com`) to a job-scoped, throwaway config dir instead of
          # the shared ~/.docker/config.json. Docker stores that auth as plaintext base64 (its
          # "credentials stored unencrypted" warning), so keeping it out of $HOME and deleting it in
          # the cleanup step below means the CLOUDFLARE_API_TOKEN-derived credential never lingers
          # where a later step, artifact upload, or cache could pick it up.
          DOCKER_CONFIG: ${{ runner.temp }}/docker-push-config
        run: |
          set -o pipefail
          mkdir -p "$DOCKER_CONFIG"
          npx --yes wrangler@4.105.0 containers push "${{ steps.ref.outputs.image }}:${{ steps.ref.outputs.tag }}" 2>&1 \
            | tee /tmp/push.log
          npx --yes wrangler@4.105.0 containers push "${{ steps.ref.outputs.image }}:latest"
          # The tag push ends with the manifest digest line; :latest is the same manifest.
          # `|| true`: under bash -e a failing $(grep …) assignment would abort the step before
          # the pointed error below could print (the runner-deploy.yml lesson).
          digest=$(grep -oE 'sha256:[0-9a-f]{64}' /tmp/push.log | tail -1 || true)
          if [ -z "$digest" ]; then
            echo "::error::could not extract the pushed image digest from the wrangler push output — the deploy job pins the containers block by digest and cannot roll out without it"
            exit 1
          fi
          echo "pushed digest: $digest"
          echo "digest=$digest" >> "$GITHUB_OUTPUT"

      - name: Scrub stored registry credentials
        # Defense-in-depth: even though the runner is ephemeral, remove the throwaway docker config
        # holding the plaintext registry auth as soon as the push is done (or has failed), so the
        # token isn't readable by anything that runs afterward. `always()` so a failed push still
        # cleans up.
        if: always()
        env:
          DOCKER_CONFIG: ${{ runner.temp }}/docker-push-config
        run: |
          docker logout registry.cloudflare.com || true
          rm -rf "$DOCKER_CONFIG"

  # Roll the freshly pushed image out to the container application. CAUTION (learned the hard
  # way, 2026-07): `wrangler deploy` diffs the container application by its image REFERENCE
  # STRING — an unchanged mutable tag like `:v1` reports "no changes" and creates NO rollout,
  # even when the registry tag now points at a new digest. Production runners then boot stale
  # images forever (the "Mac Runner"/clone-failed incident). So the deploy pins wrangler.jsonc
  # to the exact digest build-push captured; the reference changes whenever the image content
  # does, which is what forces the rollout.
  #
  # The deploy itself is runner-deploy.yml, called as a reusable workflow: this used to be a
  # verbatim second copy of that job, and the two had already drifted. runner-deploy.yml owns
  # the shared `runner-worker-deploy` concurrency group, so a code deploy and this image
  # rollout still cannot interleave.
  deploy-worker:
    needs: build-push
    uses: ./.github/workflows/runner-deploy.yml
    with:
      digest: ${{ needs.build-push.outputs.image_digest }}
    secrets: inherit
