name: CI · Test

# Reusable stage 2 - test: lint, format, type-check (tsc), the unit/integration test suites
# for the pnpm/TS workspace, plus the Helm chart gate (lint + render + helm-unittest), the
# OpenTofu module gate (fmt + offline validate), and the docs-site build gate (apps/docs
# Docusaurus, M45-1). Called by ci.yml. Python-free since #806: the whole toolchain is pnpm/TS
# (the Python API's suite retired with it; apps/api is deleted in #808).
#
# Jobs are gated (#788) on the ci-changes.yml path-filter booleans the caller passes in, so a
# PR only pays for the stacks its diff can affect: the infra gates (helm/tofu/deploy-scripts)
# skip when no deploy asset changed. Every input defaults to 'true' so a caller that passes
# nothing still gets the full pipeline (as does any push to main - ci-changes forces all gates
# open there).

on:
  workflow_call:
    inputs:
      web:
        description: "'true' to run the web/TS workspace suite (test-web)"
        type: string
        default: 'true'
      infra:
        description: "'true' to run the Helm/OpenTofu/deploy-script gates"
        type: string
        default: 'true'
      docs:
        description: "'true' to run the docs-site build gate (apps/docs Docusaurus)"
        type: string
        default: 'true'

permissions:
  contents: read

jobs:
  # Deployment-manifest gate (M24-2): the Helm chart must lint, render, and pass its
  # helm-unittest suites (assertions + manifest snapshots) on every PR.
  helm:
    name: Helm chart
    if: ${{ inputs.infra == 'true' }}
    runs-on: bloom-arc
    steps:
      - uses: actions/checkout@v4

      - uses: azure/setup-helm@v4
        with:
          version: v3.16.4

      - name: Install helm-unittest
        # Idempotent: the self-hosted runner persists $HOME between jobs, so a plain
        # `plugin install` fails with "plugin already exists" on the 2nd+ run.
        run: |
          helm plugin uninstall unittest 2>/dev/null || true
          helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.8.2

      - name: Lint chart (defaults + every env overlay)
        run: |
          helm lint deploy/helm/bloom
          for env in dev staging prod; do
            helm lint deploy/helm/bloom -f "deploy/helm/bloom/values-${env}.yaml"
          done

      - name: Render manifests (defaults + every env overlay, swarm enabled, SHA tag)
        run: |
          helm template bloom deploy/helm/bloom > /dev/null
          for env in dev staging prod; do
            helm template bloom deploy/helm/bloom \
              -f "deploy/helm/bloom/values-${env}.yaml" \
              --set image.tag="${GITHUB_SHA}" --set swarm.enabled=true > /dev/null
          done

      - name: Chart tests (helm-unittest)
        run: helm unittest deploy/helm/bloom

  # Provisioning-module gate (M24-8): the OpenTofu module (deploy/tofu) must format-check
  # and statically validate on every PR - offline and $0: `-backend=false` skips the declared
  # (M25-provisioned) state backend, and no cloud credentials, plan, or apply are involved.
  tofu:
    name: OpenTofu module
    if: ${{ inputs.infra == 'true' }}
    runs-on: bloom-arc
    steps:
      - uses: actions/checkout@v4

      # Install OpenTofu by direct, retry-wrapped, checksum-verified download.
      # We do NOT use opentofu/setup-opentofu@v1: its release-binary download
      # fails transiently on the ARC runner with a bare `AggregateError`, and it
      # has no retry - that flake red-lit two milestone PRs (#570, #579) and burns
      # the driver's CI-fix budget every time. A curl retry loop survives the blip;
      # the plain binary on PATH is all the fmt/init steps need.
      - name: Install OpenTofu 1.12.5 (retry-wrapped, checksum-verified)
        run: |
          set -euo pipefail
          VER=1.12.5
          SHA256=dade9650e6b74fc7a8b986bd8717497d32f9e09cf82e479afef4977fa3085536
          URL="https://github.com/opentofu/opentofu/releases/download/v${VER}/tofu_${VER}_linux_amd64.zip"
          for attempt in 1 2 3 4 5; do
            if curl -fsSL --retry 3 --retry-all-errors --connect-timeout 15 -o /tmp/tofu.zip "$URL"; then
              break
            fi
            if [ "$attempt" = 5 ]; then echo "OpenTofu download failed after 5 attempts"; exit 1; fi
            echo "download attempt ${attempt} failed; retrying in $((attempt * 5))s"; sleep "$((attempt * 5))"
          done
          echo "${SHA256}  /tmp/tofu.zip" | sha256sum -c -
          # Install to a runner-writable dir and prepend it to PATH. The ARC runner
          # image bakes an older tofu at /usr/local/bin that this step cannot
          # overwrite (Permission denied), so do not extract there.
          DEST="${RUNNER_TEMP}/opentofu"
          mkdir -p "$DEST"
          unzip -o /tmp/tofu.zip tofu -d "$DEST"
          chmod +x "$DEST/tofu"
          echo "$DEST" >> "$GITHUB_PATH"
          "$DEST/tofu" version

      - name: Format check
        run: tofu fmt -check -recursive -diff deploy/tofu

      - name: Init (no backend, no credentials - providers pinned by the lock file)
        run: tofu -chdir=deploy/tofu init -backend=false -input=false

      - name: Validate
        run: tofu -chdir=deploy/tofu validate -no-color

  # Deploy-script gates: the render + seal pipeline that turns Actions secrets into
  # SealedSecrets must keep its contract (M24-4: key split, verbatim values, no plaintext in
  # output), the helm deploy target must keep its remote contract (M24-5: pull-secret
  # refresh, SHA-pinned upgrade, health gate, rollback), the post-deploy e2e verdict
  # handler must keep its promote/rollback decision matrix (M24-9), and the prod smoke
  # checks must keep their contract incl. the testLogin:false guard (M24-10) - all
  # asserted offline against stub ssh/curl binaries.
  deploy-scripts:
    name: Deploy scripts
    if: ${{ inputs.infra == 'true' }}
    runs-on: bloom-arc
    steps:
      - uses: actions/checkout@v4

      - name: Install kubeseal (pinned + checksum-verified)
        run: bash deploy/k8s/sealed-secrets.sh kubeseal

      - name: Secret-delivery script tests
        run: bash scripts/deploy/tests/test_k8s_secrets.sh

      - name: Helm deploy script tests
        run: bash scripts/deploy/tests/test_helm_dev.sh

      - name: Post-deploy e2e verdict script tests
        run: bash scripts/deploy/tests/test_e2e_promote_rollback.sh

      - name: Prod post-deploy smoke script tests
        run: bash scripts/deploy/tests/test_smoke_prod.sh

  # Web/TS workspace suite (#788: gated so chart-only and docs-content-only PRs skip the
  # full workspace suite and the Playwright Chromium download; docs-content PRs pay only the
  # docs-build job below). This covers apps/server too via
  # the root `pnpm -r` scripts; the workflow-spec instance (apps/server/workflows/, the
  # runtime's source of truth since #830) is validated by the suite's zod loadSpec twin.
  test-web:
    name: Test (web)
    if: ${{ inputs.web == 'true' }}
    runs-on: bloom-arc
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10.6.3

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
          cache-dependency-path: pnpm-lock.yaml

      # One install at the workspace root covers apps/web, apps/admin, and packages/* (single
      # lockfile, #481).
      - name: Install web workspace dependencies
        run: pnpm install --frozen-lockfile

      # The root scripts fan out over every workspace package (pnpm -r), so bloom-admin (M27-5)
      # and the shared packages/* gate alongside bloom-web.
      - name: Web lint (eslint)
        run: pnpm run lint

      - name: Web format check (prettier)
        run: pnpm run format:check

      - name: Web type check (tsc)
        run: pnpm run typecheck

      # The @bloom/ui story tests (M28-2) run every story through Vitest browser mode -
      # play interactions plus the a11y (axe) pass - so the fan-out below needs a browser.
      - name: Install Playwright chromium (Storybook story tests)
        working-directory: packages/ui
        # ARC ephemeral runners are a minimal image lacking browser system libs; they run as root, so --with-deps installs them per job. (Static-VM runner had libs pre-provisioned + no sudo; that runner is decommissioned by this PR.)
        run: pnpm exec playwright install --with-deps chromium

      - name: Web test (vitest)
        run: pnpm run test

  # Docs-site gate (M45-1): apps/docs (Docusaurus) renders the repo docs/ tree in place, so
  # both an apps/docs change and a docs/ content edit can break the site build - this job
  # validates it on every PR the docs filter matches. The build is strict (M45-4): broken
  # links/markdown links throw, and it generates the TypeDoc API reference for
  # packages/api-client and packages/ui (gitignored output, produced here, never committed) -
  # so the docs filter also matches those packages' sources.
  docs-build:
    name: Docs site build
    if: ${{ inputs.docs == 'true' }}
    runs-on: bloom-arc
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10.6.3

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
          cache-dependency-path: pnpm-lock.yaml

      - name: Install web workspace dependencies
        run: pnpm install --frozen-lockfile

      - name: Docs site build (Docusaurus)
        run: pnpm --filter @bloom/docs build

  # OpenAPI drift guard (M29-1, frozen TS-side in #803): the committed spec
  # (packages/api-client/openapi.json) is the TS-owned source of truth for the contract, and the
  # committed generated types (packages/api-client/src/generated) must match it. A diff means a PR
  # edited the spec or the generated types without re-running
  # `pnpm --filter @bloom/api-client gen:api`. The guard no longer proves the spec matches the
  # FastAPI app - the spec was byte-parity with the app when frozen, and the Python side is being
  # decommissioned.
  openapi-drift:
    name: OpenAPI drift
    if: ${{ inputs.web == 'true' }}
    runs-on: bloom-arc
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10.6.3

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
          cache-dependency-path: pnpm-lock.yaml

      - name: Install web workspace dependencies
        run: pnpm install --frozen-lockfile

      - name: OpenAPI drift check (generated TS types match the frozen spec)
        run: |
          pnpm --filter @bloom/api-client run gen:api
          git diff --exit-code -- packages/api-client/openapi.json packages/api-client/src/generated

  # Admin console E2E (M27-14): the full grant -> sign-in -> browse -> mutate -> audit-row
  # journey in a real browser, against a self-contained local stack that Playwright starts
  # itself (see apps/admin/playwright.config.ts): the TS server on :8100 over an ephemeral
  # in-memory Postgres (PGlite; fake LLM, loopback IdP - the suite signs in through the REAL
  # /api/admin/auth flow; repointed off the Python API in #806) and the BUILT admin bundle on
  # :4174. Deploys only happen after main merges, so PR CI drives this local shape; the
  # deployed-dev-admin run is the documented post-deploy manual check (docs/deployment.md).
  admin-e2e:
    name: Admin E2E
    if: ${{ inputs.web == 'true' }}
    runs-on: bloom-arc
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10.6.3

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
          cache-dependency-path: pnpm-lock.yaml

      - name: Install web workspace dependencies
        run: pnpm install --frozen-lockfile

      - name: Install Playwright browser
        working-directory: apps/admin
        # ARC ephemeral runners are a minimal image lacking browser system libs; they run as root, so --with-deps installs them per job. (Static-VM runner had libs pre-provisioned + no sudo; that runner is decommissioned by this PR.)
        run: pnpm exec playwright install --with-deps chromium

      - name: Admin E2E (Playwright, local stack)
        working-directory: apps/admin
        run: pnpm run test:e2e

      - name: Upload Playwright report and traces
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: admin-e2e-playwright-report
          path: |
            apps/admin/playwright-report
            apps/admin/test-results
          retention-days: 7
          if-no-files-found: ignore
