# Pre-merge gates, single-sourced in @norskvideo/ctl-dev-kit (conventions/checks.yml)
# and drift-checked — only the ci-status-dispatch `product:` key is per-repo.
#
# Two jobs:
#   - drift: the RFC 0001 Workstream I shared-conventions drift-check. Fails if
#     this repo's forced copies have diverged from the pinned dev-kit — the
#     fenced CLAUDE.md core, nix/ctl-shell.nix, the shared config files, the manifest
#     seed. Copies exist so a human reads them on GitHub with no tooling; the
#     gate is the only thing keeping them true.
#   - quality: the repo's own lint + typecheck + unit tests + docs path lint,
#     via the shared package.json script interface every product carries. The
#     "whole project clean" convention in the CLAUDE core has no teeth without
#     this; docs:check is what keeps README/CLAUDE/docs paths true.
#
# Both run the same proven way build-image does — inside `nix develop .#build`
# (which provides bun) on the x64 self-hosted pool — but skip the Docker image
# build, so they also gate pull requests. To re-sync after a legitimate dev-kit
# change: bump the @norskvideo/ctl-dev-kit pin, then copy the new files in.
name: checks

on:
  push:
    branches: [main]
  pull_request:
  # build-image.yml calls this as half of the publish gate, with report off so
  # the board gets one `checks` cell per push, not two.
  workflow_call:
    inputs:
      report:
        type: boolean
        default: true

permissions:
  contents: read

jobs:
  drift:
    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
      - name: Shared-conventions drift-check (Workstream I)
        run: |
          nix develop .#build --command bash -c '
            set -euo pipefail
            # Belt-and-braces now that the checkout cleans: a persisted
            # node_modules would defeat a frozen install, which does NOT relink a
            # dep whose pinned version moved (a dev-kit bump leaves a stale
            # node_modules/@norskvideo/ctl-dev-kit, so check:drift would run the
            # OLD checker against the new canonical). Cheap when already gone.
            find . -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
            bun install --frozen-lockfile
            bun run check:drift
          '
      - 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

  quality:
    runs-on: x64
    outputs:
      quarantined: ${{ steps.quarantine.outputs.quarantined }}
      quarantine_failed: ${{ steps.quarantine.outputs.quarantine_failed }}
    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
      - name: Lint + typecheck + unit tests
        run: |
          nix develop .#build --command bash -c '
            set -euo pipefail
            # Belt-and-braces now that the checkout cleans: a persisted
            # node_modules would defeat a frozen install, which does NOT relink a
            # dep whose pinned version moved (a dev-kit bump leaves a stale
            # node_modules/@norskvideo/ctl-dev-kit, so check:drift would run the
            # OLD checker against the new canonical). Cheap when already gone.
            find . -name node_modules -type d -prune -exec rm -rf {} + 2>/dev/null || true
            bun install --frozen-lockfile
            bun run lint
            bun run typecheck
            # Known-flaky tests (.github/quarantine.json) leave the gating run
            # here and run on their own in the next step (conventions/quarantine.ts).
            q=node_modules/@norskvideo/ctl-dev-kit/conventions/quarantine.ts
            bun "$q" check
            BUN_OPTIONS="$(bun "$q" bun-options)" bun run test:unit
            bun run docs:check
            # Every doc-guide caption within the spoken budget, read from the
            # source (conventions/caption-check.ts): the runtime lint only runs
            # when a guide regenerates, which for the engine tier is the nightly.
            bun node_modules/@norskvideo/ctl-dev-kit/conventions/caption-check.ts
            # The tree must resolve ONE zod 4 (conventions/zod-singleton.ts).
            # studio guards this in-repo but never shipped the guard; products
            # are where studio/ctl/moq-watch zod ranges meet, so a split slips
            # in here and breaks a schema handed across the instance boundary.
            bun node_modules/@norskvideo/ctl-dev-kit/conventions/zod-singleton.ts
          '
      # The quarantined tests, each run on its own and reported: a summary
      # table, a warning per failure, and counts for the checks cell's detail.
      # It never fails the job -- that is what quarantine means -- and it runs
      # even when the gate is red, so the flake's record has no holes. Nothing
      # listed, nothing run.
      - name: Quarantined tests (run and reported, not gating)
        id: quarantine
        if: ${{ !cancelled() }}
        continue-on-error: true
        run: nix develop .#build --command bun node_modules/@norskvideo/ctl-dev-kit/conventions/quarantine.ts run

      # The two halves of one licence guarantee, run together because they only
      # work as a pair: check-template asserts this repo RECORDS the entitlement
      # it needs, and check-product-name asserts it ASKS for the one it records.
      #
      # Either alone leaves them free to drift. funke-pegasus recorded
      # funke-pegasus and asked for "norsk-studio" -- Studio's baked default,
      # used whenever NORSK_PRODUCT_NAME is unset -- for its entire life. The
      # template check was green throughout, because the template was never the
      # wrong half. What that cost: the handshake was refused, so every media
      # node was refused, so no SRT listener bound, while the instance reported
      # healthy and Studio served its API. It reads as a broken workflow, and it
      # took most of a day to find.
      #
      # PRODUCT_NAME is read ONCE and handed to both: two readings could
      # disagree, and a lint that passes while naming the wrong product is worse
      # than no lint. Cheap, no secrets, every PR.
      #
      # The image name is CI's own rule: PUBLISH_IMAGE when set, else the build
      # script's IMAGE_TAG default, tag stripped -- a licence matches the repo
      # part alone, never the tag.
      - name: Check this repo records, and asks for, its own licence
        run: |
          set -euo pipefail
          product="$(sed -nE 's/.*PRODUCT_NAME[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' shared/src/version.ts | head -1)"
          [ -n "$product" ] || { echo "::error::could not read PRODUCT_NAME from shared/src/version.ts"; exit 1; }
          pub="$(sed -nE 's/^[[:space:]]*PUBLISH_IMAGE:[[:space:]]*"?([^" ]*)"?[[:space:]]*$/\1/p' .github/workflows/build-image.yml | head -1)"
          def="$(sed -nE 's/.*IMAGE_TAG:-([^}]+)\}.*/\1/p' deployment/build-image.sh | head -1)"
          ref="${pub:-$def}"
          # A colon is a tag only after the last slash (a registry host may carry a port).
          image="${ref%:*}"
          case "${ref##*/}" in *:*) ;; *) image="$ref" ;; esac
          [ -n "$image" ] || { echo "::error::could not determine the image this repo builds"; exit 1; }
          nix develop .#build --command bun \
            node_modules/@norskvideo/ctl-dev-kit/licence/check-template.ts \
            licenseTemplate.json "$product" "$image"
          # The compose the product renders is where the declaration has to be;
          # the check exits 1 on an unreadable file rather than passing quietly,
          # so a repo that moves this module fails here instead of silently
          # dropping the gate.
          nix develop .#build --command bun \
            node_modules/@norskvideo/ctl-dev-kit/licence/check-product-name.ts \
            shared/src/product-template.ts "$product"
      - 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 this pipeline's result to the aggregated product CI dashboard
  # (id3as/ci-workflows) instead of posting its own pony — the dashboard renders
  # the pony/emoji from the dispatched result. !cancelled() so a real pass/fail
  # reports, but a cancelled/superseded run does NOT — a cancellation dispatched
  # as failure would false-red the board on every drained or concurrency-
  # superseded run.
  notify:
    needs: [drift, quality]
    if: ${{ !cancelled() && github.event_name == 'push' && inputs.report != false }}
    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: checks
          status: ${{ steps.meta.outputs.state }}
          # Quarantined tests leave the gate, never the board: say how many.
          detail: "${{ needs.quality.outputs.quarantined > 0 && format('quarantined: {0} test(s), {1} not passing this run (not gating; .github/quarantine.json)', needs.quality.outputs.quarantined, needs.quality.outputs.quarantine_failed) || '' }}"
      - 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
