# Nightly: regenerate this product's illustrated manual and publish it as the
# product's "latest docs" artifact, for the norsk-ctl docs site to republish.
#
# THE CROSS-PRODUCT PATTERN (each product does this; norsk-ctl aggregates):
#   - Every product's nightly rebuilds its manual to ONE self-contained
#     `manual.html` and publishes it as the asset of a MOVING `docs-latest`
#     GitHub Release. Latest-only: the release is never versioned, its single
#     asset is clobbered each night. Stable URL:
#       https://github.com/<owner>/<repo>/releases/download/docs-latest/manual.html
#   - norsk-ctl's docs build fetches each registered product's `docs-latest`
#     asset and mounts it under `/products/<slug>/`, deploying the one
#     Cloudflare Pages project — so ctl republishes every product's manual
#     whenever it builds its docs.
#
# This workflow is SINGLE-SOURCED in @norskvideo/ctl-dev-kit
# (conventions/publish-docs.yml) and copied verbatim, same as checks.yml: only
# the `product:` value below is per-repo (set it to this repo's dashboard key).
# Everything else 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
# so the drift gate can keep every product's copy otherwise byte-identical.
#
# Nightly-only by design: a guide's engine tier stands up real instances (slow,
# and subject to teardown flakes), so it does not ride every push. The
# deterministic tiers decide this workflow's result; the engine tier reports
# without failing it (norsk-ctl task 57), and a failed engine tier keeps
# yesterday's manual rather than publishing one with gaps. The
# licensed-runner setup mirrors integration.yml — ctl comes from the released
# binary via NORSK_CTL_BINARY, Docker is used directly on the x64 pool.
name: publish-docs

on:
  schedule:
    - cron: "30 1 * * *" # 01:30 UTC — ahead of ctl's nightly docs build
  workflow_dispatch:
    inputs:
      publish:
        description: "Publish the regenerated manual. Off runs every guide tier and reports the result without touching the docs-latest release (a branch dry run)."
        type: boolean
        default: true

permissions:
  contents: write # move the docs-latest release + clobber its asset

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

jobs:
  publish:
    runs-on: x64
    outputs:
      engine: ${{ steps.engine.outcome }}
    # One guide run per repo at a time, nightly and publish gate alike: both
    # launch the same instance ids on the x64 pool's shared docker daemon, and
    # ctl names each compose project after its instance id. Queue, never cancel.
    concurrency:
      group: doc-guides-${{ github.repository }}
      cancel-in-progress: false
    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

      # The deterministic tiers (fixture and frontend: no daemon, no instances),
      # then the manual assembled from what they captured. These gate. The engine
      # tier runs on its own below: it stands up real instances, is known-flaky
      # on container DNS (norsk-ctl task 57), and reports without gating until
      # that is fixed (ci-workflows docs/dashboard/ci-fragility.md, proposal E).
      # regen-manual.sh owns the slug list and honours SKIP_ENGINE; it rebuilds
      # every workspace the manual depends on first, and with-display.sh gives
      # headless chromium an X display.
      - name: Regenerate the illustrated manual (deterministic 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
          SKIP_ENGINE: "1"
        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 engine tier: real instances, driven in a browser. Reported, not
      # gating (see the deterministic step). A non-zero exit is forgiven only
      # when the tier's own results say a teardown hook timed out after every
      # guide's test passed (doc-guide/engine-verdict.ts), as regen-manual.sh
      # forgives it. A product with no docs:guides:engine script has no engine
      # tier. Runs on the workspaces the step before it installed and built.
      - name: Run the engine-tier guides (reported, not gating)
        id: engine
        continue-on-error: true
        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
            if ! jq -e ".scripts[\"docs:guides:engine\"]" package.json >/dev/null; then
              echo "no docs:guides:engine script: this product has no engine tier"
              exit 0
            fi
            export DOCS_GENERATE=1
            log="$(mktemp)"
            if bash scripts/with-display.sh bun run docs:guides:engine >"$log" 2>&1; then
              cat "$log"
            else
              cat "$log"
              verdict="$(bun -e "console.log(Bun.resolveSync(\"@norskvideo/ctl-dev-kit/doc-guide/engine-verdict\", process.cwd()))")"
              bun "$verdict" "$log"
            fi
          '

      # Only after a passing engine tier: the deterministic step assembled a
      # manual with the engine pages as gaps.
      - name: Assemble the manual with the engine captures
        if: ${{ steps.engine.outcome == 'success' }}
        run: nix develop .#build --command bun run docs:manual

      # The captures, for the product's own docs site: publish-docs-site.yml
      # downloads the newest docs-captures artifact, so its recorded guides
      # publish from this run, the only one that can make them. Only after a
      # passing engine tier, so a site never takes a half-captured set over the
      # last good one. An artifact, not actions/cache: a cache entry is
      # versioned by its compression, and the runner pool mixes machines with
      # and without zstd, so a save on one never matched a restore on another
      # (relay, 2026-10-21: saved on ashton-nuc, missed on test2-runner-1).
      - name: Keep the guide captures for the docs site
        if: ${{ steps.engine.outcome == 'success' }}
        uses: actions/upload-artifact@v4
        with:
          name: docs-captures
          path: docs/generated
          retention-days: 30
          if-no-files-found: error

      # Said three ways, so a failure is never silent: a warning on the run, a
      # line in its summary, and the cell's detail (the notify job).
      - name: Report the engine tier (not gating)
        if: ${{ !cancelled() }}
        env:
          OUTCOME: ${{ steps.engine.outcome }}
        run: |
          case "$OUTCOME" in
            success) echo "engine-tier guides passed (or this product has none)" ;;
            failure)
              echo "::warning title=engine-tier guides failed (not gating)::the engine tier does not gate until norsk-ctl task 57 is fixed. The manual was not republished: yesterday's stays. See the engine step's log."
              printf '### Engine-tier guides: failed (not gating)\n\nThe deterministic guide tiers gate; the engine tier only reports until its container networking is fixed (norsk-ctl task 57). The manual was not republished: the previous one stays. See the step log.\n' >> "$GITHUB_STEP_SUMMARY" ;;
            *) echo "engine tier: ${OUTCOME:-not run}" ;;
          esac

      # Publish the assembled manual as the moving docs-latest release asset.
      # gh is on PATH inside the nix shell; `upload --clobber` replaces the
      # same-named asset in place, so the release stays latest-only.
      - name: Publish manual.html to the moving docs-latest release
        if: ${{ steps.engine.outcome == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish) }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          nix develop .#build --command bash -c '
            set -euo pipefail
            # Publish the STANDALONE document (complete <!doctype>…</html>), not
            # the Artifact fragment index.html — ctl hosts it directly as a page.
            man="docs/generated/manual/manual.html"
            [ -s "$man" ] || { echo "::error::manual was not generated at $man"; exit 1; }
            # This asset is published ALONE, so it must carry its own images.
            # A manual referencing sidecars renders as a page of broken images
            # on the ctl docs site, and the only symptom is a release
            # link-check going red days later -- fail here, where the cause is
            # visible. Every <img> chunk must carry a data: URI; the leading
            # dot matches the attribute quote so no escaping is needed here.
            # Counted rather than tested with grep -qv, whose empty-input exit
            # status differs between GNU and BSD grep.
            imgs="$(grep -oE "<img[^>]*src=[^>]*" "$man" || true)"
            external="$(printf %s "$imgs" | grep -cv "src=.data:" || true)"
            if [ -n "$imgs" ] && [ "${external:-0}" -gt 0 ]; then
              echo "::error::manual.html references external images -- publish the self-contained standalone (buildManual standaloneHtml), not the sidecar variant"
              printf %s "$imgs" | grep -v "src=.data:" | head -5
              exit 1
            fi
            cp "$man" "$RUNNER_TEMP/manual.html"
            if ! gh release view docs-latest >/dev/null 2>&1; then
              gh release create docs-latest --title "Docs (latest)" --prerelease \
                --notes "Latest product manual, rebuilt nightly and republished by the norsk-ctl docs site. Not a versioned release — the manual.html asset is clobbered each night."
            fi
            gh release upload docs-latest "$RUNNER_TEMP/manual.html" --clobber
            echo "published -> docs-latest/manual.html"
          '

      # The docs BUNDLE, versioned, behind a moving pointer on the SAME release
      # (fleet review 03 s3.5 slice 6). `manual.html` above is unchanged -- it is
      # what today's ctl docs site mounts at /products/<key>/ -- and this is what
      # the public plane ingests as pages.
      #
      # Versioned assets on one release, not a release per commit: a release per
      # commit would leave hundreds of prereleases on each product repo, and a
      # single clobbered asset would leave nothing to roll back to. Only
      # latest.json moves, so a rollback is a pointer edit and the ctl site never
      # reads an asset being overwritten underneath it.
      #
      # Bun.resolveSync rather than a node_modules path: it honours the package's
      # exports map, so this works whether ctl-dev-kit is the published package or
      # a workspace link (scripts/set-ctl-source.ts local mode).
      - name: Publish the versioned docs bundle and move the pointer
        if: ${{ steps.engine.outcome == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish) }}
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          # How many bundles to keep on the release, the current one included.
          BUNDLE_KEEP: "5"
        run: |
          nix develop .#build --command bash -c '
            set -euo pipefail
            bundle="docs/generated/bundle"
            [ -s "$bundle/bundle.json" ] || { echo "::error::no docs bundle at $bundle/bundle.json"; exit 1; }
            rel="$(bun -e "console.log(Bun.resolveSync(\"@norskvideo/ctl-dev-kit/doc-guide/docs-release\", process.cwd()))")"
            version="$(bun "$rel" version "$GITHUB_SHA")"
            asset="$(bun "$rel" asset "$version")"
            tar -czf "$RUNNER_TEMP/$asset" -C "$bundle" .
            gh release upload docs-latest "$RUNNER_TEMP/$asset" --clobber
            bun "$rel" pointer --version "$version" --sha "$GITHUB_SHA" \
              --run-id "$GITHUB_RUN_ID" --built-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$RUNNER_TEMP/latest.json"
            gh release upload docs-latest "$RUNNER_TEMP/latest.json" --clobber
            gh release view docs-latest --json assets \
              --jq "[.assets[] | {name: .name, createdAt: .createdAt}]" \
              | bun "$rel" prune "$BUNDLE_KEEP" "$asset" \
              | while read -r old; do
                  echo "pruning $old"
                  gh release delete-asset docs-latest "$old" --yes
                done
            echo "published -> docs-latest/$asset, pointer moved"
          '

      # Wake the public docs site rather than making it wait for its own cron.
      # CI_DISPATCH_TOKEN, not GITHUB_TOKEN: repository_dispatch to ANOTHER repo
      # needs Contents: write there, and the built-in token is scoped to this one.
      # The payload carries the repo, not a product key: norsk-ctl resolves the
      # key through ci-workflows' registry, so a new product needs no new mapping
      # here. Not wrapped in nix -- plain curl, and the quoting stays readable.
      - name: Tell norsk-ctl a new bundle is published
        if: ${{ steps.engine.outcome == 'success' && (github.event_name != 'workflow_dispatch' || inputs.publish) }}
        env:
          DISPATCH_TOKEN: ${{ secrets.CI_DISPATCH_TOKEN }}
        run: |
          set -euo pipefail
          body="$(printf '{"event_type":"product-docs-published","client_payload":{"repo":"%s"}}' "$GITHUB_REPOSITORY")"
          code="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
            -H "Authorization: Bearer $DISPATCH_TOKEN" \
            -H "Accept: application/vnd.github+json" \
            "https://api.github.com/repos/id3as/norsk-ctl/dispatches" -d "$body")"
          [ "$code" = "204" ] || { echo "::error::docs-published dispatch to norsk-ctl failed (HTTP $code)"; exit 1; }
          echo "dispatched product-docs-published -> id3as/norsk-ctl"
      - 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

  # Report the result to the aggregated product CI dashboard (id3as/ci-workflows)
  # as the `docs` cell. Red as well as green: the guides failed every night for a
  # month while the board showed nothing, because this workflow never reported.
  # A dry run (publish off) stays off the board -- it may be a branch.
  notify:
    needs: [publish]
    if: ${{ !cancelled() && (github.event_name != 'workflow_dispatch' || inputs.publish) }}
    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

      - uses: actions/checkout@v5
        with:
          clean: true
      - id: meta
        run: |
          if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
            echo "state=failure" >> "$GITHUB_OUTPUT"
          else
            echo "state=success" >> "$GITHUB_OUTPUT"
          fi
      - uses: ./.github/actions/ci-status-dispatch
        with:
          token: ${{ secrets.CI_DISPATCH_TOKEN }}
          product: __PRODUCT__
          pipeline: docs
          status: ${{ steps.meta.outputs.state }}
          # The engine tier reports, never gates; a failure keeps yesterday's manual.
          detail: "${{ needs.publish.outputs.engine == 'failure' && 'engine-tier guides failed (not gating; norsk-ctl task 57): the manual was not republished' || '' }}"
      - 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
