name: CI · Test

# Reusable stage 2 - test, as three jobs:
# - web: lint, format, type-check (tsc) and the unit/integration suites for the pnpm/TS
#   workspace, the admin console E2E, and the OpenAPI drift guard.
# - infra: the Helm chart gate (lint + render + helm-unittest), the OpenTofu module gate (fmt +
#   offline validate) and the deploy-script contract tests.
# - sites: the docs-site build gate (apps/docs Docusaurus, M45-1) and the landing-site build
#   gate (apps/landing static prerender, M46-1).
# Called by ci.yml. Application gates use pnpm/TS; private artifact transfer uses Python.
#
# Why three jobs and not one per gate: every job waits for one of the two bloom-arc runner slots
# and repeats checkout + toolchain set-up, so a gate that takes 15 s of work cost a slot plus
# ~30 s of overhead on its own. Within a job, each gate step runs as long as the set-up it needs
# succeeded - `!cancelled()` instead of the default `success()` - so a lint failure still
# reports the test results, as separate jobs did.
#
# Jobs and steps are gated (#788) on the path-filter booleans the caller passes in, so a PR only
# pays for the stacks its diff can affect. Every input defaults to 'true' so a caller that passes
# nothing still gets the full pipeline (as does any push to main - the filter is forced open
# there).

on:
  workflow_call:
    secrets:
      R2_ARTIFACTS_ACCESS_KEY_ID:
        required: false
      R2_ARTIFACTS_SECRET_ACCESS_KEY:
        required: false
    inputs:
      web:
        description: "'true' to run the web/TS workspace suite (the web job)"
        type: string
        default: "true"
      infra:
        description: "'true' to run the Helm/OpenTofu/deploy-script gates (the infra job)"
        type: string
        default: "true"
      docs:
        description: "'true' to run the docs-site build gate (apps/docs Docusaurus)"
        type: string
        default: "true"
      landing:
        description: "'true' to run the landing-site build gate (apps/landing static prerender)"
        type: string
        default: "true"

permissions:
  contents: read

jobs:
  # Web/TS workspace suite (#788: gated so chart-only and docs-content-only PRs skip it). 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.
  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: 22
          cache: pnpm
          cache-dependency-path: pnpm-lock.yaml

      # One install at the workspace root covers apps/*, and packages/* (single lockfile, #481).
      - name: Install web workspace dependencies
        id: install
        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)
        if: ${{ !cancelled() && steps.install.outcome == 'success' }}
        run: pnpm run lint

      - name: Web format check (prettier)
        if: ${{ !cancelled() && steps.install.outcome == 'success' }}
        run: pnpm run format:check

      - name: Web type check (tsc)
        if: ${{ !cancelled() && steps.install.outcome == 'success' }}
        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. The
      # bloom-arc runner image bakes Chromium, its headless shell and their system libraries
      # (tools/bloom-arc-runner), so this finds them in place and downloads nothing unless a
      # Playwright bump moves the browser revision. No --with-deps: the libraries are in the
      # image, and apt-get would only spend time refreshing package lists.
      - name: Install Playwright chromium (Storybook story tests)
        id: ui-browser
        if: ${{ !cancelled() && steps.install.outcome == 'success' }}
        working-directory: packages/ui
        run: pnpm exec playwright install chromium

      - name: Web test (vitest)
        if: ${{ !cancelled() && steps.ui-browser.outcome == 'success' }}
        run: pnpm run test

      # 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) 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). Its Playwright may pin a different
      # revision from packages/ui's, so it checks its own browser (same baked-image rule).
      - name: Install Playwright chromium (admin E2E)
        id: admin-browser
        if: ${{ !cancelled() && steps.install.outcome == 'success' }}
        working-directory: apps/admin
        run: pnpm exec playwright install chromium

      - name: Admin E2E (Playwright, local stack)
        if: ${{ !cancelled() && steps.admin-browser.outcome == 'success' }}
        working-directory: apps/admin
        run: pnpm run test:e2e

      - name: Upload the admin E2E Playwright report and traces
        if: ${{ !cancelled() && steps.admin-browser.outcome == 'success' }}
        uses: hidden-claw/actions-r2/upload@c9ede64f3969d05d137c055a5d8339a9db7cabd7
        env:
          R2_ARTIFACTS_ACCESS_KEY_ID: ${{ secrets.R2_ARTIFACTS_ACCESS_KEY_ID }}
          R2_ARTIFACTS_SECRET_ACCESS_KEY: ${{ secrets.R2_ARTIFACTS_SECRET_ACCESS_KEY }}
          R2_ARTIFACTS_BUCKET: ${{ vars.R2_ARTIFACTS_BUCKET }}
          R2_ARTIFACTS_ENDPOINT: ${{ vars.R2_ARTIFACTS_ENDPOINT }}
        with:
          name: admin-e2e-playwright-report
          path: |
            apps/admin/playwright-report
            apps/admin/test-results
          retention-days: 7
          if-no-files-found: ignore

      # 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`. Last in the job because it regenerates files
      # in the working tree, which no other step should see.
      - name: OpenAPI drift check (generated TS types match the frozen spec)
        if: ${{ !cancelled() && steps.install.outcome == 'success' }}
        run: |
          pnpm --filter @bloom/api-client run gen:api
          git diff --exit-code -- packages/api-client/openapi.json packages/api-client/src/generated

  # Deploy-asset gates, skipped when no deploy asset changed:
  # - Helm chart (M24-2): the chart must lint, render, and pass its helm-unittest suites
  #   (assertions + manifest snapshots).
  # - OpenTofu module (M24-8): deploy/tofu must format-check and statically validate - offline
  #   and $0: `-backend=false` skips the declared (M25-provisioned) state backend, and no cloud
  #   credentials, plan, or apply are involved.
  # - Deploy scripts: 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.
  infra:
    name: Infra (Helm, OpenTofu, deploy scripts)
    if: ${{ inputs.infra == 'true' }}
    runs-on: bloom-arc
    steps:
      - uses: actions/checkout@v4

      # The chart gate and the dev LLM smoke-script test (which renders the chart to assert the
      # exec target matches the real server Deployment name, #949) share this pinned helm.
      - uses: azure/setup-helm@v4
        id: helm
        with:
          version: v3.16.4

      - name: Install helm-unittest
        id: helm-unittest
        # Idempotent: a runner that persists $HOME between jobs fails a plain `plugin install`
        # 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)
        if: ${{ !cancelled() && steps.helm.outcome == 'success' }}
        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)
        if: ${{ !cancelled() && steps.helm.outcome == 'success' }}
        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)
        if: ${{ !cancelled() && steps.helm-unittest.outcome == 'success' }}
        run: helm unittest deploy/helm/bloom

      # 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)
        id: tofu
        if: ${{ !cancelled() }}
        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: OpenTofu format check
        if: ${{ !cancelled() && steps.tofu.outcome == 'success' }}
        run: tofu fmt -check -recursive -diff deploy/tofu

      - name: OpenTofu init (no backend, no credentials - providers pinned by the lock file)
        id: tofu-init
        if: ${{ !cancelled() && steps.tofu.outcome == 'success' }}
        run: tofu -chdir=deploy/tofu init -backend=false -input=false

      - name: OpenTofu validate
        if: ${{ !cancelled() && steps.tofu-init.outcome == 'success' }}
        run: tofu -chdir=deploy/tofu validate -no-color

      - name: Install kubeseal (pinned + checksum-verified)
        id: kubeseal
        if: ${{ !cancelled() }}
        run: bash deploy/k8s/sealed-secrets.sh kubeseal

      - name: Secret-delivery script tests
        if: ${{ !cancelled() && steps.kubeseal.outcome == 'success' }}
        run: bash scripts/deploy/tests/test_k8s_secrets.sh

      - name: Helm deploy script tests
        if: ${{ !cancelled() && steps.kubeseal.outcome == 'success' }}
        run: bash scripts/deploy/tests/test_helm_dev.sh

      - name: Post-deploy e2e verdict script tests
        if: ${{ !cancelled() && steps.kubeseal.outcome == 'success' }}
        run: bash scripts/deploy/tests/test_e2e_promote_rollback.sh

      - name: Prod post-deploy smoke script tests
        if: ${{ !cancelled() && steps.kubeseal.outcome == 'success' }}
        run: bash scripts/deploy/tests/test_smoke_prod.sh

      - name: Dev post-deploy LLM smoke script tests
        if: ${{ !cancelled() && steps.kubeseal.outcome == 'success' && steps.helm.outcome == 'success' }}
        run: bash scripts/deploy/tests/test_smoke_llm_dev.sh

      - name: po-gateway token reconciliation script tests
        if: ${{ !cancelled() && steps.kubeseal.outcome == 'success' }}
        run: bash scripts/deploy/tests/test_sync_po_gateway_token.sh

  # Static-site build gates, one job for both sites:
  # - Docs site (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. 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.
  # - Landing site (M46-1): apps/landing must produce its prerendered static output (tsc, the
  #   client bundle, the SSR bundle, and the prerender script that bakes the page markup into
  #   dist/index.html) on every run the landing filter matches (apps/landing itself, plus the
  #   @bloom/ui sources and shared config it compiles).
  # Nothing here deploys: the deploy workflows rebuild each site at publish time.
  sites:
    name: Sites (docs, landing)
    if: ${{ inputs.docs == 'true' || inputs.landing == '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: 22
          cache: pnpm
          cache-dependency-path: pnpm-lock.yaml

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

      - name: Docs site build (Docusaurus)
        if: ${{ !cancelled() && inputs.docs == 'true' && steps.install.outcome == 'success' }}
        run: pnpm --filter @bloom/docs build

      - name: Landing site build (static prerender)
        id: landing
        if: ${{ !cancelled() && inputs.landing == 'true' && steps.install.outcome == 'success' }}
        run: pnpm --filter bloom-landing build

      # Guard the SEO contract (M46-1): the shipped index.html must carry real page markup, not
      # just the empty shell a broken prerender step would leave behind.
      - name: Assert prerendered HTML in dist/index.html
        if: ${{ !cancelled() && steps.landing.outcome == 'success' }}
        run: |
          grep -q '<h1' apps/landing/dist/index.html
          ! grep -q '<!--app-html-->' apps/landing/dist/index.html
