name: gate
# Org-standard green-before-merge gate for product repos (#1333). Runs on PRs, train-branch pushes,
# and v* tags so /release and /hotfix can discover required contexts on the tagged SHA.
#
# Runner: self-hosted mmi-runner (mmi-live) — see docs/Guides/gh-runner-runbook.md in MMI-Hub.
# #4118: if GATE_CMD runs tsgo or other memory-heavy typecheck / browser work, pin this job to
#   runs-on: [self-hosted, linux, x64, mmi-heavy]
# instead — standard lanes are MemoryMax=1200M and will cgroup-OOM those workloads.
# Stack-aware (#1550): the runtime is rendered into the setup-step `if:` — both the Node and Python
# setup steps live in the file but only the one matching GATE_RUNTIME runs. Knobs (override at
# bootstrap with --var):
#   GATE_RUNTIME       — node | python (selects which setup step fires)
#   GATE_CMD           — the check command ({{GATE_CMD}})
#   GATE_INSTALL_CMD   — the dependency-install command ({{GATE_INSTALL_CMD}})
#   GATE_WORKDIR       — app working directory when not the repo root ({{GATE_WORKDIR}})
#   GATE_CACHE_DEP_PATH— npm lockfile path for runner-checkout heal ({{GATE_CACHE_DEP_PATH}})
#   GATE_PY_VERSION    — Python version for setup-python ({{GATE_PY_VERSION}})
#   GATE_MAX_SECONDS   — run-with-budget wall-clock ceiling in seconds ({{GATE_MAX_SECONDS}}); 300 default,
#                        tighten per repo (org project set --var gate={"maxSeconds":N}) once measured (#3178)
#   GATE_PUSH_BRANCHES_YAML — YAML list of push branches that trigger the workflow file (rendered by
#                        bootstrap from the release track; not usually hand-passed)
#   GATE_FULL_RUN_BRANCH — train branch whose PUSHES run the full gate job (development on full/direct;
#                        main on trunk). `main` is ALWAYS included in the job `if:` below so a required-
#                        checks ruleset on main cannot be silently skipped by a future bootstrap (#4113).
# GATE_BUDGET_SHA is rendered by the CLI from its blessed run-with-budget pin — not an operator knob.
# GATE_RUNNER_SHA pins the org runner-checkout / runner-node-toolchain actions (#5610).
# Estate policy 2026-08-23: GitHub-hosted Windows (`windows-latest` / windows-*) is forbidden org-wide.
# Do not add a windows-compat (or any other) job that runs-on GH-hosted Windows. Linux self-hosted
# (mmi-live / mmi-heavy) is the only CI merge gate. Rare Windows proof stays on the owner's machine or
# a future self-hosted Windows runner — never windows-latest.
# (seed touch: keep BOM digests in lockstep after classifier land.)
on:
  pull_request:
  push:
    branches: {{GATE_PUSH_BRANCHES_YAML}}
    tags: ['v*']

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

# #3001: cancel superseded runs — agent push-bursts queue a full fresh run per push.
# Per-PR group only (a repo-wide group hits GitHub's one-pending-slot limit and cancels
# innocent queued runs — MMC-ZuberShade#880). Pushes group by ref: each tag and the train
# branch supersede only themselves.
concurrency:
  group: gate-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  gate:
    # #4113: bake `main` into the template permanently. GATE_FULL_RUN_BRANCH selects the train push
    # that runs the full job; main must never become skippable via bootstrap vars alone while the
    # org ruleset still requires `gate` on main.
    if: ${{ github.event_name != 'push' || github.ref_name == '{{GATE_FULL_RUN_BRANCH}}' || github.ref_name == 'main' || github.ref_type == 'tag' }}
    runs-on: [self-hosted, linux, x64, mmi-live]
    defaults:
      run: { working-directory: {{GATE_WORKDIR}} }
    steps:
      - uses: mutmutco/MMI-Hub/.github/actions/runner-checkout@{{GATE_RUNNER_SHA}}
        with:
          lockfile-path: {{GATE_CACHE_DEP_PATH}}
      # #5427: per-job npm cache under $RUNNER_TEMP — never the shared ~/.npm. Twelve mmi-live lanes
      # share the gha-runner home; npm hardlinks from that cache into node_modules, so one job's
      # `npm ci` can make another job's vitest/dist/workers/forks.js vanish mid-suite (Cannot find
      # module). Job-local cache ⇒ no cross-job hardlinks. Must land BEFORE setup-node (which reads
      # `npm config get cache` for any explicit cache restore). Job-level `env:` cannot use the
      # `runner` context — write via GITHUB_ENV from the shell `$RUNNER_TEMP` instead.
      - name: Isolate npm cache (per-job)
        shell: bash
        run: echo "NPM_CONFIG_CACHE=$RUNNER_TEMP/npm-cache" >> "$GITHUB_ENV"
      # MMI-Hub#5111: Windows CRLF in tracked shell scripts breaks Linux runners before the real
      # check can explain it. Inspect only tracked files before install; .gitattributes is the fix.
      - name: Line-ending guard
        shell: bash
        run: |
          offenders=$(mktemp)
          trap 'rm -f "$offenders"' EXIT
          {
            git grep --cached -Il $'\r' -- '*.sh' || true
            # #6481: git grep exits 1 on zero matches; under pipefail a repo with no shebang file died here.
            { git grep --cached -Il '^#!' || true; } | while IFS= read -r path; do
              case "$path" in
                *.sh) ;;
                *) git grep --cached -Il $'\r' -- "$path" || true ;;
              esac
            done
          } | sort -u > "$offenders"
          if [[ -s "$offenders" ]]; then
            while IFS= read -r path; do
              printf "::error title=Line-ending guard::CR byte in tracked script: %s. Run git add --renormalize . with this repo's .gitattributes.\n" "$path"
            done < "$offenders"
            exit 1
          fi
      # #5660: self-hosted gates opt OUT of setup-node automatic npm cache restore unless a bounded
      # cache policy is declared on runner-node-toolchain (cache + cache-restore-max-mb). Per-job
      # NPM_CONFIG_CACHE above is the org default; unbounded Actions cache blobs can exhaust mmi-live
      # disks mid-extract before the real check runs (Jerv-Hub SQLCipher gate, ~4.3GB restore).
      # MMI-Hub#5871: declare the npm MAJOR rather than inheriting whatever the runner's node bundles.
      # A bare major, never an exact patch (it goes stale and then prescribes downgrading operator
      # hosts toward it, #5862) and never `latest` (it takes the next major silently, #4782).
      - if: ${{ '{{GATE_RUNTIME}}' == 'node' }}
        uses: mutmutco/MMI-Hub/.github/actions/runner-node-toolchain@{{GATE_RUNNER_SHA}}
        with:
          node-version: 24
          npm-version: '12'
      - if: ${{ '{{GATE_RUNTIME}}' == 'python' }}
        uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
        with: { python-version: '{{GATE_PY_VERSION}}' }
      # MMI-Hub#3446: assert the runtime toolchain is on PATH BEFORE the install step. Node uses the
      # runner-node-toolchain composite above (setup-node + preflight in one action, #5610). Python
      # keeps the inline preflight below.
      - name: Toolchain preflight
        if: ${{ '{{GATE_RUNTIME}}' == 'python' }}
        run: |
          python3 --version || { echo "::error title=CI runner toolchain::RUNNER NOT PROVISIONED — python3 not on PATH after setup-python. This is the runner, not your diff; no install or test ran. Re-run the job; if it persists the mmi-runner python toolchain needs attention (docs/Guides/gh-runner-runbook.md in MMI-Hub)."; exit 1; }
      - run: {{GATE_INSTALL_CMD}}
      # #3178: the check runs under the org wall-clock budget — a hung suite dies at the ceiling instead
      # of squatting a runner lane for GitHub's 6h default. ci-audit + the release train enforce this step.
      - uses: mutmutco/MMI-Hub/.github/actions/run-with-budget@{{GATE_BUDGET_SHA}}
        with:
          command: {{GATE_CMD}}
          max-seconds: {{GATE_MAX_SECONDS}}
          working-directory: {{GATE_WORKDIR}}
