# Nightly: regenerate this product's docs bundle and bake it into the product
# image. The EMBEDDED variant of the docs workflow (ADR-0012): the bundle is
# built, put in the image the way a release build puts it there, and read back
# out of that image as the proof. NOTHING LEAVES THE REPO -- no release asset,
# no upload, no write permission on the repo, no token held. A turnkey's documentation
# reaches exactly one customer, inside the image they already run.
#
# THE TWO VARIANTS (a repo carries exactly one; the drift gate enforces it):
#   - build-docs.yml   (this file)  embedded: bundle -> image, full stop.
#                                   Turnkeys (norsk-ctl-turnkey-*) carry this.
#   - publish-docs.yml              public: the same build, then the bundle is
#                                   published for the norsk-ctl docs site to
#                                   ingest. ONLY a norsk-ctl-product-* repo may
#                                   carry it -- the gate refuses it elsewhere.
#
# This workflow is SINGLE-SOURCED in @norskvideo/ctl-dev-kit
# (conventions/build-docs.yml) and copied verbatim, same as checks.yml. It is
# deliberately product-agnostic: everything per-repo lives in repo-local scripts
# it calls --
#   - scripts/doc-guide/regen-manual.sh   : the guide tiers + slug list + assemble
#   - scripts/doc-guide/prepull-extra.sh  : OPTIONAL, any images beyond media+studio
#   - deployment/build-image.sh           : the product image wrapper (dev-kit driver)
# so the drift gate can keep every copy byte-identical. A turnkey with prose-only
# pages (no doc-guides yet) has a regen-manual.sh that just assembles.
#
# Nightly-only by design, mirroring publish-docs.yml: a guide's engine tier
# stands up real instances (slow, licensed), so it does not ride every push.
name: build-docs

on:
  schedule:
    - cron: "30 1 * * *" # 01:30 UTC — ahead of ctl's nightly docs build
  workflow_dispatch:

permissions:
  contents: read # builds and bakes; nothing leaves the repo

concurrency:
  group: build-docs-${{ github.ref }}
  cancel-in-progress: false

jobs:
  build:
    runs-on: x64
    steps:
      # Runs BEFORE checkout, and does two things: points the harness's temp base
      # out of the workspace for every later step in this job, and clears whatever
      # the old repo-local default left behind on this runner. The body carries the
      # why. It fails the job only when legacy litter survives -- which would take
      # the checkout down a step later anyway, but report nothing useful.
      - name: Route test-temp out of the workspace (pre-checkout)
        run: |
          set -uo pipefail
          # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
          # HOST daemon has not seen before is created by dockerd, as ROOT, on the
          # host -- even when the launch passes --container-user (reproduced
          # 2026-09-04). The harness's default base is repo-local for an OrbStack
          # inotify constraint that binds macOS dev and nothing here, so every run
          # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
          # cannot remove. That took commentary's checks AND integration red on
          # 2026-09-03. Use the sanctioned override, per-run and outside the
          # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
          base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
          mkdir -p "$base"
          echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
          # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
          # EACCESes. They cannot wedge a checkout, but they do fill the disk.
          for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
            docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
          done
          # LEGACY: a runner that ran the old default still carries a workspace
          # test-temp, and a checkout with clean:true dies on it. Reap the holders
          # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
          # RESTARTING leaked container re-creates the path as root seconds after
          # any clean. Match on mount SOURCE, not a label: the holders are untracked
          # by construction, because a killed run labels nothing.
          tt="$GITHUB_WORKSPACE/test-temp"
          [ -d "$tt" ] || exit 0
          stuck=""
          for c in $(docker ps -aq 2>/dev/null || true); do
            if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
              stuck="$stuck $c"
            fi
          done
          if [ -n "$stuck" ]; then
            echo "reaping containers holding mounts under $tt:$stuck"
            docker rm -f $stuck || true
          fi
          # The DIRECTORY, not just its contents: an empty but root-owned base
          # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
          # container unlink the leaf.
          docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
            sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
          # Fail HERE if it survived. The checkout fails on it either way, but
          # reports only an EACCES rmdir with no clue what was holding the path.
          if [ -e "$tt" ]; then
            echo "::error::$tt survived the pre-checkout clean"
            ls -lan "$tt" || true
            exit 1
          fi

      # Runs BEFORE checkout, and does two things: points the harness's temp base
      # out of the workspace for every later step in this job, and clears whatever
      # the old repo-local default left behind on this runner. The body carries the
      # why. It fails the job only when legacy litter survives -- which would take
      # the checkout down a step later anyway, but report nothing useful.
      - name: Route test-temp out of the workspace (pre-checkout)
        run: |
          set -uo pipefail
          # These runners are docker-OUTSIDE-of-docker, so a bind-mount source the
          # HOST daemon has not seen before is created by dockerd, as ROOT, on the
          # host -- even when the launch passes --container-user (reproduced
          # 2026-09-04). The harness's default base is repo-local for an OrbStack
          # inotify constraint that binds macOS dev and nothing here, so every run
          # seeded the CHECKOUT with root-owned dirs a non-root `git clean -ffdx`
          # cannot remove. That took commentary's checks AND integration red on
          # 2026-09-03. Use the sanctioned override, per-run and outside the
          # checkout -- as 8ff17a8d did in June for one debug workflow, and stopped.
          base="$RUNNER_TEMP/nctt-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT"
          mkdir -p "$base"
          echo "NORSK_CTL_TEST_TMP=$base" >> "$GITHUB_ENV"
          # Yesterday's bases hold dockerd-created root dirs, so a non-root rm
          # EACCESes. They cannot wedge a checkout, but they do fill the disk.
          for old in $(find "$RUNNER_TEMP" -maxdepth 1 -name 'nctt-*' -mtime +0 -printf '%f\n' 2>/dev/null || true); do
            docker run --rm --user 0:0 -v "$RUNNER_TEMP":/t alpine sh -c 'rm -rf -- "/t/$1"' sh "$old" >/dev/null 2>&1 || true
          done
          # LEGACY: a runner that ran the old default still carries a workspace
          # test-temp, and a checkout with clean:true dies on it. Reap the holders
          # first -- rm cannot unlink a live mountpoint even as root (EBUSY), and a
          # RESTARTING leaked container re-creates the path as root seconds after
          # any clean. Match on mount SOURCE, not a label: the holders are untracked
          # by construction, because a killed run labels nothing.
          tt="$GITHUB_WORKSPACE/test-temp"
          [ -d "$tt" ] || exit 0
          stuck=""
          for c in $(docker ps -aq 2>/dev/null || true); do
            if docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null | grep -q "^$tt/"; then
              stuck="$stuck $c"
            fi
          done
          if [ -n "$stuck" ]; then
            echo "reaping containers holding mounts under $tt:$stuck"
            docker rm -f $stuck || true
          fi
          # The DIRECTORY, not just its contents: an empty but root-owned base
          # still EACCESes a later mkdtemp. Mounting the PARENT is what lets a root
          # container unlink the leaf.
          docker run --rm --user 0:0 -v "$GITHUB_WORKSPACE":/w alpine \
            sh -c 'rm -rf /w/test-temp' || rm -rf "$tt" || true
          # Fail HERE if it survived. The checkout fails on it either way, but
          # reports only an EACCES rmdir with no clue what was holding the path.
          if [ -e "$tt" ]; then
            echo "::error::$tt survived the pre-checkout clean"
            ls -lan "$tt" || true
            exit 1
          fi

      - uses: actions/checkout@v5
        with:
          clean: true

      - name: Write the Norsk license (from the org secret)
        env:
          NORSK_LICENSE_V2: ${{ secrets.NORSK_LICENSE_V2 }}
        run: printf '%s' "$NORSK_LICENSE_V2" > "$RUNNER_TEMP/norsk-license.json"

      # Every norsk-ctl daemon starts the node MoQ relay (norsk-moq-relay) unless
      # told not to, and nothing removes it: it is a node singleton, and the reap
      # step only takes containers that mount this job's test-temp. So the first
      # test daemon on a shared runner left one, and every later one adopted it
      # (probe's smoke ran in relay mode on 2026-10-10; relay's smoke lost 4443
      # to it). This job's daemons run without the node relay and discovery, so
      # they neither start nor adopt one, and whatever is already on the host is
      # named here. It reports and never fails: the leftover may be someone's.
      - name: Isolate this job from node singletons other jobs left on this host
        run: |
          set -uo pipefail
          echo "NORSK_CTL_MOQ_RELAY_ENABLE=0" >> "$GITHUB_ENV"
          echo "NORSK_CTL_DISCOVERY_ENABLE=0" >> "$GITHUB_ENV"
          left="$(docker ps -a --filter label=norsk-ctl.role --format '{{.Names}} {{.Label "norsk-ctl.role"}} {{.Status}} (created {{.CreatedAt}})' 2>/dev/null || true)"
          if [ -z "$left" ]; then echo "no norsk-ctl containers on this host"; exit 0; fi
          echo "norsk-ctl containers already on this host (other jobs' running instances are expected):"
          echo "$left"
          while IFS= read -r line; do
            role="$(awk '{print $2}' <<<"$line")"
            case "$role" in
              moq-relay|discovery)
                echo "::warning title=node singleton left on this host::$line -- left by an earlier job (a daemon's auto-start, or a run killed before its reap). This job's daemons run without it and will not adopt it; remove it with docker rm -f if nothing on this host needs it." ;;
            esac
          done <<<"$left"

      # The norsk-ctl this commit pins (flake.nix ctlVersion, moved by
      # bump.yml's nightly PR), never S3's moving `latest`: a guide run is then
      # a real repeat, and a red belongs to a commit (ci-fragility proposal A).
      - name: Download the pinned norsk-ctl binary (flake.nix ctlVersion)
        run: |
          set -euo pipefail
          S3="https://s3.eu-west-1.amazonaws.com/norsk.video/norsk-ctl"
          ver="$( { sed -nE 's/^[[:space:]]*ctlVersion = "([^"]+)";.*/\1/p' flake.nix 2>/dev/null || true; } | head -1)"
          [ -n "$ver" ] || { echo "::error::flake.nix pins no ctlVersion. CI runs the norsk-ctl this commit pins (the nightly bump.yml moves it); add ctlVersion = \"<version>\"; to flake.nix."; exit 1; }
          echo "norsk-ctl pinned by flake.nix ctlVersion -> $ver"
          curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 "$S3/$ver/norsk-ctl-$ver-linux-x64" -o "$RUNNER_TEMP/norsk-ctl"
          chmod +x "$RUNNER_TEMP/norsk-ctl"
          "$RUNNER_TEMP/norsk-ctl" --version || true

      # Cold-runner first pulls overrun the harness's per-test launch timeouts;
      # pre-pull the universal media + studio images (every product pins them in
      # manifest.seed.json) so compose/run hit local images. A product needing
      # more (e.g. a WHIP driver) pulls them in scripts/doc-guide/prepull-extra.sh.
      - name: Pre-pull the media + studio images
        run: |
          set -euo pipefail
          # An sdk-app has no seed: its prepull-extra hook pulls its engine.
          if [ -f manifest.seed.json ]; then
            for img in "$(jq -r '.latest.media' manifest.seed.json)" "$(jq -r '.latest.studio' manifest.seed.json)"; do
              echo "pre-pulling $img"
              docker pull "$img"
            done
          fi
          if [ -x scripts/doc-guide/prepull-extra.sh ]; then
            echo "running scripts/doc-guide/prepull-extra.sh"
            ./scripts/doc-guide/prepull-extra.sh
          fi

      # Rebuild every workspace the bundle depends on (a guide's engine tier packs
      # the freshly-built dashboard + frontend dist into the launched template),
      # then run the product's doc-guide tiers and assemble the bundle.
      # regen-manual.sh owns the slug list and which tiers run; with-display.sh
      # wraps the whole run in an X display for headless chromium (the engine tier
      # drives a raw chromium that needs one).
      - name: Regenerate the docs bundle (all tiers)
        env:
          NORSK_CTL_BINARY: ${{ runner.temp }}/norsk-ctl
          NORSK_LICENSE_FILE: ${{ runner.temp }}/norsk-license.json
          # This runner launches Studio as a HOST sibling (DooD), so the harness
          # reaches host-published ports via the host-gateway alias, not
          # localhost — same as the integration suite.
          NORSK_TEST_HOST: host.docker.internal
          # Reach launched instances over norsk-net by service DNS (as the
          # integration tier already does) AND, on a ctl that supports it, launch
          # them binding no host ports — so concurrent doc-guide instances can't
          # collide on the product's deterministic host-port bands.
          NORSK_TEST_NET: direct
        run: |
          nix develop .#build --command bash -c '
            set -euo pipefail
            # Belt-and-braces now that the checkout cleans: a persisted node_modules
            # workspace dep whose version moved leaves stale copies that
            # --frozen-lockfile does not reliably relink, so a new export is "not
            # found". Nuke EVERY node_modules — including nested per-workspace ones
            # — for a deterministic install. A nightly can afford it.
            find . -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
            bun install --frozen-lockfile
            bun run build:no-lint
            bash scripts/with-display.sh scripts/doc-guide/regen-manual.sh
          '

      # The proof: the bundle the tiers just built goes into the product image
      # the way a release build puts it there (deployment/build-image.sh stages
      # docs/generated/bundle/ as the image's docs/), and the image is asked for
      # its manifest. A bundle that does not build, or does not reach the image,
      # fails here — on the commit that broke it. The image is local to the
      # runner: nothing is pushed, nothing is uploaded, no token is held.
      - name: Bake the bundle into the product image and read it back
        env:
          IMAGE_TAG: build-docs-bake:${{ github.sha }}
        run: |
          nix develop .#build --command bash -c '
            set -euo pipefail
            man="docs/generated/bundle/bundle.json"
            [ -s "$man" ] || { echo "::error::bundle was not generated at $man"; exit 1; }
            bash deployment/build-image.sh
            docker run --rm --entrypoint /usr/local/bin/bun "$IMAGE_TAG" -e "
              const m = require(\"/usr/src/app/docs/bundle.json\");
              console.log(\"image carries docs bundle schema\", m.schemaVersion, \"with\", m.pages.length, \"pages, built\", m.provenance.builtAt);
            "
            docker image rm -f "$IMAGE_TAG" >/dev/null
          '
      - name: Reap the containers this job launched
        # Whatever the tests did. A teardown cut short by a test timeout leaves
        # its instance running on the shared Docker daemon (see JOB_REAP_STEP in
        # conventions/workflow-shell.ts); everything this job launched mounts its
        # own test-temp, so that is what goes.
        if: always()
        run: |
          base="${NORSK_CTL_TEST_TMP:-}"
          tt="$GITHUB_WORKSPACE/test-temp"
          s=""
          for c in $(docker ps -aq); do
            src="$(docker inspect -f '{{range .Mounts}}{{println .Source}}{{end}}' "$c" 2>/dev/null)" || continue
            if { [ -n "$base" ] && grep -q "^$base/" <<<"$src"; } || grep -q "^$tt/" <<<"$src"; then s="$s $c"; fi
          done
          if [ -n "$s" ]; then
            echo "::warning::removing containers this job left running:$s"
            docker rm -f $s
          fi
