# Publish this product's DOCS SITE -- the public manual at <product>.norsk.video
# (website/, an Astro + Starlight site on the shared Norsk theme) -- to
# Cloudflare Pages on every push to main.
#
# This is the SITE channel, not the bundle: publish-docs.yml / build-docs.yml
# regenerate the illustrated manual the product image serves at /docs, nightly,
# from a licensed Docker run. The site needs no licence and no Docker, and so
# rides every push (conventions/docs-site.md, "Two documentation channels").
# A site that also symlinks RECORDED guides (website/src/content/docs/guides/
# <slug> -> docs/generated/<slug>) publishes them from the captures that nightly
# run keeps as an artifact: they are restored before the build, which
# stubs only a guide never captured, and the gate below refuses to deploy a
# stub. So the site also runs when publish-docs completes, to publish a fresh
# capture without waiting for the next push.
#
# This workflow is SINGLE-SOURCED in @norskvideo/ctl-dev-kit
# (conventions/publish-docs-site.yml) and copied verbatim: there is no per-repo
# line. Everything per-site lives in the repo -- website/astro.config.mjs, the
# root `docs:site:public` script -- or in the repo's settings:
#
#   variable NORSK_DOCS_SITE_PROJECT   the Cloudflare Pages project name.
#                                      NO DEFAULT: unset means the project does
#                                      not exist yet, and the deploy step says
#                                      what to create and exits 0. So main is
#                                      green before Cloudflare exists, and the
#                                      site publishes the day the variable is
#                                      set, with no change here.
#   secret   CLOUDFLARE_API_TOKEN      a Pages:Edit token covering that project
#   variable CLOUDFLARE_ACCOUNT_ID     the account the project lives in: not a
#                                      secret, and the org already shares it as
#                                      a variable (norsk-ctl reads it the same way)
#
# The site is BUILT whether or not it will be deployed: if the build is only
# exercised on the day the Pages project appears, that day is when we find out
# it was broken.
#
# The drift gate (check-drift.ts) holds the copy byte-identical, allows it only
# in a repo that has a site (website/astro.config.mjs), and refuses it in a
# turnkey (norsk-ctl-turnkey-*): a turnkey has no public site, its documentation
# reaches one customer inside the image they run (ADR-0012).
name: publish-docs-site

on:
  push:
    branches: [main]
  workflow_dispatch:
  # a fresh capture of the recorded guides (publish-docs.yml saves it)
  workflow_run:
    workflows: [publish-docs]
    types: [completed]
    branches: [main]

permissions:
  contents: read
  actions: read # the newest docs-captures artifact, from the capture run

# Never cancel in flight: a cancelled `wrangler pages deploy` is a half-published
# site, and the next push rebuilds from the same inputs anyway. Queue instead.
concurrency:
  group: publish-docs-site
  cancel-in-progress: false

jobs:
  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

      # The recorded guides' captures, from the product's nightly capture run
      # (publish-docs.yml keeps docs/generated as the docs-captures artifact
      # after a passing engine tier): the newest unexpired one, put where
      # docs:site:public expects it. None yet -- a site that records no guides,
      # or before the first capture -- restores nothing; the build stubs what is
      # missing and the gate below refuses to deploy a stub. gh comes from the
      # build shell (the runners have no system gh).
      - name: Restore the recorded guides' captures
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          nix develop .#build --command bash -c '
            set -euo pipefail
            run="$(gh api "repos/$GITHUB_REPOSITORY/actions/artifacts?name=docs-captures&per_page=20" \
              --jq "[.artifacts[] | select(.expired | not)][0].workflow_run.id // empty")"
            if [ -z "$run" ]; then
              echo "no docs-captures artifact yet: nothing to restore"
              exit 0
            fi
            mkdir -p docs/generated
            gh run download "$run" --repo "$GITHUB_REPOSITORY" --name docs-captures --dir docs/generated
            echo "restored the recorded guides captured by run $run"
          '

      # website/ is a STANDALONE package -- its own bun.lock, outside the root
      # workspaces -- so the repo-root install never covers it, and the build
      # needs both: the root for @norskvideo/ctl-dev-kit (docs:site:public stubs
      # the recorded guides through its docs/guide-stubs.ts, and the gate below
      # reads them the same way), website/ for Astro. Frozen, as every CI
      # install is: a lockfile the build rewrites is drift nobody sees. The
      # public variant (ASTRO_BUILD=public) is base /; the embedded one is base
      # /docs, and every link in it is wrong on the public domain -- which is
      # what the grep at the end catches.
      - name: Build the public docs site
        run: |
          nix develop .#build --command bash -c '
            set -euo pipefail
            bun install --frozen-lockfile
            bun install --frozen-lockfile --cwd website
            bun run docs:site:public
            dist="website/dist-public"
            [ -s "$dist/index.html" ] || { echo "::error::$dist/index.html was not built"; exit 1; }
            if grep -q "href=./docs/" "$dist/index.html"; then
              echo "::error::$dist is the EMBEDDED variant (links are /docs-prefixed); docs:site:public must set ASTRO_BUILD=public"
              exit 1
            fi
            echo "built $dist ($(find "$dist" -name "*.html" | wc -l) pages)"
          '

      # A recorded guide whose capture is not on this runner is a placeholder
      # page after the build above (docs:site:public stubs it so the build
      # works). Right for a local build, wrong for a deploy: the live page would
      # be replaced with "run the doc-guide locally", and nothing else would
      # notice -- the build is green, the links resolve. --content reads the
      # guides the site declares by symlink, so a site that declares none has
      # nothing to refuse; one that does needs its captures, restored above,
      # and until the nightly run has captured them this step says which pages
      # are missing.
      - name: Refuse to publish placeholder guides
        run: |
          nix develop .#build --command bash -c '
            set -euo pipefail
            bun run node_modules/@norskvideo/ctl-dev-kit/docs/guide-stubs.ts check --content website/src/content/docs/guides
          '

      # Skipped -- loudly, and successfully -- while NORSK_DOCS_SITE_PROJECT is
      # unset: the Pages project and the DNS record are the account owner's to
      # create, and main must not go red waiting for them. Setting the variable
      # is the whole switch-on.
      #
      # The token is verified at the API, not with `wrangler whoami`: whoami
      # needs User Details:Read, which a Pages:Edit token deliberately lacks. Two
      # kinds of token verify at two endpoints: a USER-owned token (My Profile ->
      # API Tokens) answers /user/tokens/verify; an ACCOUNT-owned one (Manage
      # Account -> API Tokens) returns 1000 "Invalid API Token" there however
      # valid it is, and answers /accounts/<id>/tokens/verify. Checking only the
      # first once made a valid team-owned token read like a bad secret. curl is
      # on the host PATH; wrangler is not in the shared dev shell, so bunx fetches
      # it inside `nix develop`, where bun is.
      #
      # --branch is the ref that pushed: main is the production branch of the
      # Pages project, so a push to main publishes; a workflow_dispatch from any
      # other branch publishes a PREVIEW deployment of that branch, never over
      # production.
      - name: Deploy to Cloudflare Pages
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          NORSK_DOCS_SITE_PROJECT: ${{ vars.NORSK_DOCS_SITE_PROJECT }}
          PAGES_BRANCH: ${{ github.ref_name }}
        run: |
          set -euo pipefail
          if [ -z "${NORSK_DOCS_SITE_PROJECT:-}" ]; then
            origin="$(sed -nE 's/.*PUBLIC_SITE_ORIGIN = "([^"]+)".*/\1/p' website/src/lib/site.ts | head -1)"
            echo "── Docs site → SKIPPED (no Pages project configured) ──"
            echo "   The site built (website/dist-public) but NORSK_DOCS_SITE_PROJECT is unset, so"
            echo "   nothing is deployed. To turn publishing on:"
            echo "     1. Create a Cloudflare Pages project for this site (e.g. $(basename "$GITHUB_REPOSITORY")-docs)."
            echo "     2. Add its custom domain${origin:+ (${origin#https://})} and the DNS record."
            echo "     3. Make sure CLOUDFLARE_API_TOKEN (Pages:Edit) covers that project, and that"
            echo "        the variable CLOUDFLARE_ACCOUNT_ID reaches this repo."
            echo "     4. Set the repo variable NORSK_DOCS_SITE_PROJECT to the project name."
            echo "   Nothing in this workflow changes. See @norskvideo/ctl-dev-kit conventions/docs-site.md, Publishing."
            exit 0
          fi
          if [ -z "${CLOUDFLARE_API_TOKEN:-}" ] || [ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then
            echo "::error::NORSK_DOCS_SITE_PROJECT=$NORSK_DOCS_SITE_PROJECT is set, but this repo needs both the secret CLOUDFLARE_API_TOKEN and the variable CLOUDFLARE_ACCOUNT_ID"
            exit 1
          fi
          cf_verify() {
            curl -fsS --retry 3 --retry-all-errors --retry-delay 2 \
              -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
              "https://api.cloudflare.com/client/v4/$1" 2>&1
          }
          if verify_body=$(cf_verify "user/tokens/verify"); then
            token_kind="user-owned"
          elif verify_body=$(cf_verify "accounts/$CLOUDFLARE_ACCOUNT_ID/tokens/verify"); then
            token_kind="account-owned"
          else
            echo "::error::Cloudflare token verification failed at both /user/tokens/verify and /accounts/<id>/tokens/verify. Last reply: $verify_body"
            exit 1
          fi
          if ! echo "$verify_body" | grep -q '"status":"active"'; then
            echo "::error::Cloudflare token not active ($token_kind): $verify_body"
            exit 1
          fi
          # The token id is an identifier, not a credential: it answers "which of
          # our tokens is CI using?", which a write-only secret otherwise cannot.
          token_id=$(echo "$verify_body" | sed -n 's/.*"id":"\([a-f0-9]*\)".*/\1/p' | head -1)
          echo "Cloudflare token: active ($token_kind${token_id:+, id ${token_id}})"
          echo "── Docs site → Cloudflare Pages ($NORSK_DOCS_SITE_PROJECT, branch $PAGES_BRANCH) ──"
          nix develop .#build --command bash -c '
            set -euo pipefail
            bunx wrangler@4 pages deploy website/dist-public \
              --project-name "$NORSK_DOCS_SITE_PROJECT" \
              --branch "$PAGES_BRANCH" \
              --commit-dirty=true
          '
      - 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
