name: Build narOS

# narOS build pipeline (docs/NAROS.md §8): Rust binaries (nash, naros-init) → Nucleic
# .debs → signed apt repo (R2) → mmdebstrap naros-base rootfs per arch → naros-agent
# OCI layer (os/images/agent) with smoke + parity-sweep gates → OCI images on GHCR.
#
# Channels: pushes to dev build `edge`; the weekly cron refreshes `edge` with a new
# snapshot date implicitly only when SNAPSHOT is bumped (snapshot stays pinned in-tree —
# reproducibility over freshness); `stable` is cut via workflow_dispatch. Images push
# only from dev/main/dispatch, never from PRs.
#
# Secrets (all optional — jobs degrade to unsigned/unpublished artifacts without them):
#   NAROS_APT_SIGNING_KEY / NAROS_APT_KEYID   ascii-armored private key + key id
#   R2_ACCOUNT_ID / R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_BUCKET
#   R2_JURISDICTION                  optional: default, eu, or fedramp

on:
  push:
    branches: [main, dev]
    paths:
      - os/**
      - shell/**
      - .github/workflows/naros.yml
  schedule:
    - cron: "17 6 * * 1" # weekly edge rebuild
  workflow_dispatch:
    inputs:
      channel:
        description: "Release channel"
        type: choice
        options: [edge, stable]
        default: edge

env:
  CHANNEL: ${{ inputs.channel || 'edge' }}

jobs:
  binaries:
    name: binaries — nash, naros-init, guest agents, identity shim (${{ matrix.arch }})
    runs-on: ubuntu-latest
    strategy:
      matrix:
        include:
          - arch: amd64
            target: x86_64-unknown-linux-musl
          - arch: arm64
            target: aarch64-unknown-linux-musl
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      # cargo-zigbuild (zig as the musl cross-linker) instead of `cross`: cross mounts
      # only the cargo workspace (shell/) into its build container, which would hide the
      # ../third_party/brush path dependencies.
      - name: Install cargo-zigbuild
        uses: taiki-e/install-action@v2
        with:
          tool: cargo-zigbuild
      - name: Install zig
        run: pip3 install ziglang
      - name: Build nash + naros-init (Rust musl static, ${{ matrix.arch }})
        working-directory: shell
        run: cargo zigbuild --release --target ${{ matrix.target }} -p nash -p naros-init
      - name: Build nucleic-a11y-agent (Rust musl static, ${{ matrix.arch }})
        # The AT-SPI semantic agent (guest/nucleic-a11y-agent), packaged as a deb for the desktop VM
        # tier. Built for BOTH arches through the same cargo-zigbuild/musl path as nash above: it used
        # to be built only natively on arm64 (linux-vm-agents.yml), so the amd64 deb silently skipped
        # staging and x86_64 guests shipped with no ax_* agent at all. Its deps (tokio, tokio-vsock,
        # zbus with default-features off) are pure Rust, so there is no C sysroot to cross-provide.
        working-directory: guest/nucleic-a11y-agent
        run: cargo zigbuild --release --target ${{ matrix.target }}
      - name: Build nucleic-linux-agent (static C, ${{ matrix.arch }})
        # The Linux-VM guest vsock control-plane agent (guest/nucleic-linux-agent), packaged as a deb
        # for the naros-vm tier (NAROS.md §3.1, §7.4). Cross-compiled with the same zig toolchain
        # cargo-zigbuild already uses above, so the whole os/ binary set comes from one linker.
        run: |
          case "${{ matrix.arch }}" in
            amd64) zt=x86_64-linux-musl ;;
            arm64) zt=aarch64-linux-musl ;;
          esac
          mkdir -p os/dist/bin
          python3 -m ziglang cc -target "$zt" -O2 -static -pthread \
            -o os/dist/bin/nucleic-linux-agent-${{ matrix.arch }} \
            guest/nucleic-linux-agent/src/agent.c
      - name: Install cross toolchain for the identity shim
        if: matrix.arch == 'arm64'
        # libc6-dev-arm64-cross is only a Recommends of the cross gcc, so
        # --no-install-recommends drops it and gcc falls back to the runner's own
        # /usr/include — where stdlib.h pulls in the x86_64-only multiarch header
        # bits/libc-header-start.h and the compile dies. Name the target libc explicitly.
        run: |
          sudo apt-get update
          sudo apt-get install -y --no-install-recommends \
            gcc-aarch64-linux-gnu libc6-dev-arm64-cross
      - name: Build naros-identity shim (glibc shared, ${{ matrix.arch }})
        # The kernel identity interposer (NAROS.md §2.3). Stock cross-gcc, not the zig
        # toolchain above: this is the one artifact in os/ that must be a DYNAMIC glibc
        # shared object — it interposes uname(2) in glibc processes via /etc/ld.so.preload,
        # which a musl or static build could not do.
        run: os/src/naros-identity/build.sh ${{ matrix.arch }}
      - name: Stage binaries for packaging
        run: |
          mkdir -p os/dist/bin
          cp shell/target/${{ matrix.target }}/release/nash os/dist/bin/nash-${{ matrix.arch }}
          cp shell/target/${{ matrix.target }}/release/naros-init os/dist/bin/naros-init-${{ matrix.arch }}
          cp guest/nucleic-a11y-agent/target/${{ matrix.target }}/release/nucleic-a11y-agent \
             os/dist/bin/nucleic-a11y-agent-${{ matrix.arch }}
      - uses: actions/upload-artifact@v4
        with:
          name: naros-bin-${{ matrix.arch }}
          path: os/dist/bin/*

  packages:
    name: debs + signed apt repo → R2
    needs: binaries
    runs-on: ubuntu-latest
    env:
      # The secrets context is not usable in step `if:` expressions — lower presence
      # checks into env flags here (allowed) and test the flags below.
      HAS_SIGNING_KEY: ${{ secrets.NAROS_APT_SIGNING_KEY != '' }}
      HAS_R2: ${{ secrets.R2_ACCOUNT_ID != '' && secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' }}
      NAROS_RCLONE_VERSION: 1.74.2
      NAROS_RCLONE_LINUX_AMD64_SHA256: 72a806370072015ccbe4d81bcd348cc5eaf3beca6c65ba693fd43fb31fcca5b1
    steps:
      - uses: actions/checkout@v6
      - uses: actions/download-artifact@v4
        with:
          pattern: naros-bin-*
          merge-multiple: true
          path: os/dist/bin
      - name: Install packaging tools (dpkg-dev, apt-utils)
        run: sudo apt-get update && sudo apt-get install -y --no-install-recommends dpkg-dev apt-utils unzip
      - name: Import signing key
        if: ${{ env.HAS_SIGNING_KEY == 'true' }}
        env:
          NAROS_APT_SIGNING_KEY: ${{ secrets.NAROS_APT_SIGNING_KEY }}
          NAROS_APT_KEYID: ${{ secrets.NAROS_APT_KEYID }}
        run: |
          echo "$NAROS_APT_SIGNING_KEY" | gpg --batch --import
          mkdir -p os/repo/keys
          gpg --batch --export "$NAROS_APT_KEYID" > os/repo/keys/naros-archive-keyring.gpg
          echo "NAROS_APT_KEYID=$NAROS_APT_KEYID" >> "$GITHUB_ENV"
      - name: Build packages
        run: |
          chmod +x os/dist/bin/* || true
          # --require: the guest agents must exist for EVERY arch. A missing prebuilt binary otherwise
          # only warns, which is how nucleic-a11y-agent/amd64 went missing from the repo unnoticed.
          os/packages/build-all.sh --arch arm64,amd64 --channel "$CHANNEL" \
            --require nucleic-linux-agent,nucleic-a11y-agent
      - name: Publish apt tree
        run: os/repo/publish.sh --channel "$CHANNEL" ${NAROS_APT_KEYID:+--sign "$NAROS_APT_KEYID"}
      # Ubuntu's rclone package trails upstream substantially. Pin the official binary so
      # its Go TLS stack and Cloudflare R2 backend do not vary with the runner image.
      - name: Install rclone
        if: ${{ github.event_name != 'pull_request' && env.HAS_R2 == 'true' }}
        run: |
          set -euo pipefail
          archive="rclone-v${NAROS_RCLONE_VERSION}-linux-amd64.zip"
          curl --fail --location --retry 5 --retry-all-errors \
            --output "$RUNNER_TEMP/$archive" \
            "https://downloads.rclone.org/v${NAROS_RCLONE_VERSION}/$archive"
          echo "$NAROS_RCLONE_LINUX_AMD64_SHA256  $RUNNER_TEMP/$archive" | sha256sum --check --strict
          unzip -q "$RUNNER_TEMP/$archive" -d "$RUNNER_TEMP"
          mkdir -p "$RUNNER_TEMP/bin"
          install -m 0755 \
            "$RUNNER_TEMP/rclone-v${NAROS_RCLONE_VERSION}-linux-amd64/rclone" \
            "$RUNNER_TEMP/bin/rclone"
          echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
          "$RUNNER_TEMP/bin/rclone" version
      - name: Sync to R2
        if: ${{ github.event_name != 'pull_request' && env.HAS_R2 == 'true' }}
        env:
          R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
          R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
          R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
          R2_BUCKET: ${{ secrets.R2_BUCKET }}
          R2_JURISDICTION: ${{ secrets.R2_JURISDICTION }}
        run: |
          os/repo/r2-sync.sh
      - uses: actions/upload-artifact@v4
        with:
          name: naros-pool
          path: os/dist/pool/

  rootfs:
    name: base rootfs + naros-agent image, gated (${{ matrix.arch }})
    needs: packages
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    strategy:
      matrix:
        arch: [amd64, arm64]
    steps:
      - uses: actions/checkout@v6
      # Registers qemu binfmt for docker (the arm64 agent-image build + smoke runs);
      # mmdebstrap's chroot uses the apt qemu-user-static installed in Tools below.
      - uses: docker/setup-qemu-action@v3
      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: actions/download-artifact@v4
        with:
          name: naros-pool
          path: os/dist/pool
      # mmdebstrap straps a FOREIGN arch here (the amd64 runner builds the arm64 slice), so
      # this is the one caller that needs the qemu-user-static/binfmt extras.
      - uses: ./.github/actions/debootstrap-tools
        with:
          extra-packages: arch-test qemu-user-static binfmt-support
      - name: Build base rootfs (mmdebstrap, ${{ matrix.arch }})
        # Strict only when the keyring can exist (signing key provisioned); until then a
        # keyring-less pool is expected and must not fail the build.
        run: |
          sudo -E env \
            NAROS_STRICT=${{ secrets.NAROS_APT_SIGNING_KEY != '' && '1' || '0' }} \
            DEBIAN_ARCHIVE_KEYRING=/usr/share/keyrings/debian-archive-keyring.gpg \
            os/mkimage/build-rootfs.sh base ${{ matrix.arch }} \
              --pool os/dist/pool --channel "$CHANNEL"
      - name: Smoke test (base)
        run: |
          VER="$(cat os/VERSION)"
          docker import --platform "linux/${{ matrix.arch }}" \
            "os/dist/naros-base-$VER-${{ matrix.arch }}.tar" naros-test
          os/tests/smoke-rootfs.sh base naros-test ${{ matrix.arch }}
      # The agent tier is an OCI layer FROM the just-built base (NAROS.md §4, N2):
      # naros-tier-agent from the pool, NodeSource Node 22, rustup, Go, mise, caches,
      # the agent user, agent CLIs, Playwright. Under qemu on the arm64 slice — slow
      # but the same path sandbox-image.yml used for v6/v7.
      - name: Build naros-agent image
        run: os/images/agent/build.sh ${{ matrix.arch }} --base naros-test --tag naros-agent-test
      - name: Smoke test (agent)
        run: |
          docker run --rm --platform "linux/${{ matrix.arch }}" naros-agent-test /usr/bin/nash -lc '
            set -e
            . /etc/os-release; test "$ID" = naros; test "$VARIANT_ID" = agent
            test "$(id -u agent)" = 501
            # The identity tag must survive the agent layer, and must reach the runtime the
            # harness actually reads it through: os.type()+os.release() is what Claude Code
            # renders as its "OS Version:" line (NAROS.md §2.3).
            uname -r | grep -q -- "-naros$VERSION_ID"
            node -p "require(\"os\").type() + \" \" + require(\"os\").release()" | grep -q -- "^Linux .*-naros"
            sudo -l -U agent | grep -q NOPASSWD
            node --version | grep -q "^v22\."
            python3 --version > /dev/null; pip3 --version > /dev/null
            rustc --version > /dev/null; cargo --version > /dev/null
            go version > /dev/null
            swift --version > /dev/null; swiftc --version > /dev/null
            # LLDB is pruned from the Swift layer on purpose (libpython3.11 is absent on
            # trixie) — assert it stays pruned so a toolchain bump can not silently re-add
            # 338 MB of binaries that cannot load.
            test ! -e /opt/swift/usr/bin/lldb
            mise --version > /dev/null
            rg --version > /dev/null; fd --version > /dev/null
            jq --version > /dev/null; yq --version > /dev/null
            gh --version > /dev/null; yarn --version > /dev/null
            git --version > /dev/null; ssh -V 2> /dev/null || true
            claude --version; codex --version; grok --version
            playwright --version > /dev/null
            test -d /opt/playwright-browsers
            test -f /opt/nucleic/control-bridge.js
            test -x /usr/sbin/naros-init
            test "$npm_config_cache" = /opt/cache/npm
            test "$CARGO_HOME" = /opt/cache/cargo
            naros info --json | jq -e ".tier == \"agent\" and .toolchains.go != null and .toolchains.swift != null" > /dev/null'
      # A real Chromium launch (§10.3), native arch only — under qemu it is minutes-slow
      # and prone to emulation flakes that say nothing about the image.
      - name: Playwright launch test
        if: ${{ matrix.arch == 'amd64' }}
        run: |
          docker run --rm naros-agent-test /usr/bin/nash -lc \
            'NODE_PATH="$(npm root -g)" node -e "require(\"playwright\").chromium.launch({args:[\"--no-sandbox\"]}).then(b => b.close())"'
      # A real Swift build + test + link, native arch only (same reasoning as Playwright
      # above — SwiftPM under qemu is minutes-slow). `swift --version` in the smoke block
      # only proves the driver loads; this proves the stdlib, Foundation and swift-testing
      # actually link and run, which is where a distro/soname drift would surface.
      - name: Swift build test
        if: ${{ matrix.arch == 'amd64' }}
        run: |
          docker run --rm naros-agent-test /usr/bin/nash -lc '
            set -e
            cd "$(mktemp -d)"
            swift package init --type library --name Smoke > /dev/null
            swift build
            swift test
            printf "import Foundation\nprint(ProcessInfo.processInfo.processName)\n" > f.swift
            swiftc f.swift -o f && ./f > /dev/null'
      # Tool-inventory parity vs the image naros-agent replaces (N2 exit criterion):
      # anything on v7's PATH or npm-global set must exist here or be allowlisted.
      - name: Parity sweep vs nucleic-sandbox:v7
        run: |
          owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
          reference="ghcr.io/${owner}/nucleic-sandbox:v7"
          docker pull --platform "linux/${{ matrix.arch }}" "$reference"
          os/tests/parity-sweep.sh naros-agent-test "$reference"
          docker image rm "$reference"
      - name: Push to GHCR
        if: ${{ github.event_name != 'pull_request' }}
        run: |
          VER="$(cat os/VERSION)"
          owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
          base="ghcr.io/${owner}/naros-base:${VER}-${{ matrix.arch }}"
          agent="ghcr.io/${owner}/naros-agent:${VER}-${{ matrix.arch }}"
          docker tag naros-test "$base"
          docker tag naros-agent-test "$agent"
          docker push "$base"
          docker push "$agent"

  index:
    name: publish multi-arch index (base + agent)
    needs: rootfs
    if: ${{ github.event_name != 'pull_request' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v6
      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Create index
        run: |
          VER="$(cat os/VERSION)"
          owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
          for tier in base agent; do
            img="ghcr.io/${owner}/naros-${tier}"
            docker buildx imagetools create -t "$img:$VER" -t "$img:$CHANNEL" \
              "$img:$VER-amd64" "$img:$VER-arm64"
          done

  linux-vm:
    # The naros-vm rootfs flavors (NAROS.md §4, §7.4, milestones N4/N5). arm64 ONLY — the Linux VM
    # guest runs on Virtualization.framework (§2.2). Emitted as rootfs *tarball artifacts* (not OCI
    # images) that the Mac-host two-phase build boots (LINUX_VM.md): a bootable naros base + systemd,
    # onto which firstboot `apt install naros-tier-vm`s the agent + tier. Published as single-blob OCI
    # artifacts — the same distribution channel as nucleic-linux-vm-kernel.
    #
    #   vm          headless.
    #   vm-desktop  GNOME 50 / Mutter from a pinned Debian FORKY snapshot (build-rootfs.sh maps
    #               vm-desktop→forky), for computer-use + the AT-SPI semantic agent.
    #
    # ONE job over both flavors: they differed only in the build-rootfs.sh tier and their smoke
    # assertions (now os/tests/smoke-rootfs.sh). Every path derives from the flavor —
    # build-rootfs.sh emits naros-<flavor>-<ver>-<arch>.tar and the GHCR repo is naros-<flavor>.
    name: naros-${{ matrix.flavor }} rootfs (arm64)
    needs: packages
    runs-on: ubuntu-24.04-arm # native arm64 — no qemu needed to build an arm64 rootfs
    permissions:
      contents: read
      packages: write
    strategy:
      fail-fast: false # a desktop failure must not cancel the headless flavor
      matrix:
        # vm-desktop is heavy (~1.5 GB GNOME pull from snapshot.debian.org), so it is skipped on
        # PRs; a snapshot mirror-cache (§11) is future work. A job-level `if:` cannot see the
        # `matrix` context, so that gate is expressed by shortening the matrix itself.
        flavor: ${{ github.event_name == 'pull_request' && fromJSON('["vm"]') || fromJSON('["vm","vm-desktop"]') }}
    steps:
      - uses: actions/checkout@v6
      - uses: actions/download-artifact@v4
        with:
          name: naros-pool
          path: os/dist/pool
      - uses: ./.github/actions/debootstrap-tools
      - name: Build naros-${{ matrix.flavor }} rootfs (arm64)
        # Strict only when the keyring can exist (signing key provisioned); until then a keyring-less
        # pool is expected and must not fail the build (mirrors the base rootfs job).
        run: |
          sudo -E env \
            NAROS_STRICT=${{ secrets.NAROS_APT_SIGNING_KEY != '' && '1' || '0' }} \
            DEBIAN_ARCHIVE_KEYRING=/usr/share/keyrings/debian-archive-keyring.gpg \
            os/mkimage/build-rootfs.sh ${{ matrix.flavor }} arm64 --pool os/dist/pool --channel "$CHANNEL"
      - name: Smoke test (${{ matrix.flavor }})
        run: |
          VER="$(cat os/VERSION)"
          docker import --platform linux/arm64 \
            "os/dist/naros-${{ matrix.flavor }}-$VER-arm64.tar" naros-${{ matrix.flavor }}-test
          os/tests/smoke-rootfs.sh ${{ matrix.flavor }} naros-${{ matrix.flavor }}-test arm64
      - name: Compress the rootfs tarball
        run: |
          VER="$(cat os/VERSION)"
          gzip -9 -c "os/dist/naros-${{ matrix.flavor }}-$VER-arm64.tar" \
            > "os/dist/naros-${{ matrix.flavor }}-$VER-arm64.tar.gz"
      - uses: actions/upload-artifact@v4
        with:
          name: naros-${{ matrix.flavor }}-rootfs-arm64
          # The `*` stands in for os/VERSION. Each matrix leg has its own workspace and builds
          # exactly one flavor, so the vm glob cannot pick up a vm-desktop tarball.
          path: os/dist/naros-${{ matrix.flavor }}-*-arm64.tar.gz
          if-no-files-found: error
      - uses: oras-project/setup-oras@v1
        if: ${{ github.event_name != 'pull_request' }}
      - name: Log in to GHCR
        if: ${{ github.event_name != 'pull_request' }}
        run: oras login ghcr.io -u '${{ github.actor }}' -p '${{ secrets.GITHUB_TOKEN }}'
      - name: Push the rootfs as an OCI single-blob artifact
        if: ${{ github.event_name != 'pull_request' }}
        run: |
          VER="$(cat os/VERSION)"
          owner="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
          img="ghcr.io/${owner}/naros-${{ matrix.flavor }}"
          oras push "$img:$VER" \
            --artifact-type application/vnd.nucleic.rootfs \
            "os/dist/naros-${{ matrix.flavor }}-$VER-arm64.tar.gz:application/gzip"
          oras tag "$img:$VER" "$CHANNEL"
