# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
# store. The install step resolves from the baked store (--prefer-offline)
# instead of paying a ~731s cold fetch + native compile every run.
#
# PINNED to an immutable lock-tag (#1328, brain D27): ci-image.yml pushes
# lock-<sha256(pnpm-lock.yaml)[:12]> atomically with :latest, so the two are
# byte-identical at push time. A mutable :latest resolves per-pod at pull time
# on the k8s backend, which made CI verdicts non-reproducible (same tree, same
# config, different images across runs; see #1324 comment 23382/23386). The pin
# changes ONLY through reviewed commits; a wrong tag fails loudly at image pull.
#
# Bump procedure: when a recipe change (pnpm-lock.yaml / Dockerfile.ci) lands on
# main, ci-image.yml pushes lock-<new>; a follow-up PR updates this anchor.
# Until then pipelines keep the old pin: reproducible, with the documented
# network-fallback lag (frozen-lockfile resolves missing packages from network).
# Known limitation: lock- addresses the lockfile only, so a Dockerfile-only
# change re-pushes the same tag with new content (#1328 follow-up: recipe-hash).
variables:
  - &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-9cb7ffcd8828'
  - &enable_pnpm 'corepack enable'

when:
  # PR + manual CI run on any branch: the pull_request pipeline is the merge
  # gate (next is protected and the default branch since 2026-08-19).
  # Push CI runs on main only. next deliberately runs NO push ci: post-merge
  # verification on next is carried by publish.yml's `verify` step
  # (pnpm verify:release), which mirrors this pipeline's complete mandatory
  # set step-for-step, enforced by scripts/verify-release.test.mjs. PR CI
  # tests the PR HEAD tree (refs/pull/N/head, measured 2026-08-19), not a
  # merge ref, so if next advances before a merge the landed tree differs
  # from the tested one; publish verify re-runs the full set on the landed
  # tree (PGlite path). Measured 2026-08-19: the 21 most recent push events
  # on next each ran exactly one pipeline (publish), zero ci.
  # Keeping push ci off next also avoids a redundant second full-suite run
  # per merge on the storage-constrained runner.
  - event: [pull_request, manual]
  - event: push
    branch: main

# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
# deployment — never pull_request). This PR pipeline deliberately gets no
# remote-cache credentials: an untrusted PR must not be able to write to (or
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
# local cache only, which is the intended behavior here.

steps:
  install:
    image: *node_image
    commands:
      - corepack enable
      # python3/make/g++ are baked into ci-base; --prefer-offline resolves from
      # the baked pnpm store.
      - pnpm install --frozen-lockfile --prefer-offline

  # ---------------------------------------------------------------------------
  # The steps below (sanitization, upgrade-guard, typecheck, lint, format,
  # test) are the COMPLETE mandatory verification set. SDLC-D-034 mirrors them
  # one-for-one in the canonical terminal verification command — root
  # `pnpm verify:release` (scripts/verify-release.mjs) — which the publish
  # pipeline (.woodpecker/publish.yml `verify` step) runs before ANY publish
  # effect. These lines stay direct (not routed through the runner) because the
  # #1017 test-enumeration guard audits framework tool paths through THIS
  # surface; scripts/verify-release.test.mjs enforces that the runner's stage
  # table keeps matching these commands exactly, so the two cannot drift.
  # ---------------------------------------------------------------------------

  # Canonical verify:release stage `sanitization`.
  # Blocking gate: public framework package must contain no operator-specific
  # personal data or private $HOME defaults. Runs early (no node_modules needed).
  sanitization:
    image: *node_image
    commands:
      - apk add --no-cache bash
      - bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
      # Resident line-count ceiling over framework-owned resident files
      # (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
      - bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
      - bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
      # Test-membership guard (#1017): also first link of test:framework-shell.
      # Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link
      # [0] of the pnpm chain, so severing that chain would silence it together
      # with everything it guards; this direct line keeps one instrument running.
      - bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
      # Tool-index gate: a shipped wrapper that appears in no resident index doc
      # is undiscoverable from inside a session, and an agent that cannot learn a
      # wrapper exists reaches for raw curl instead — which is how a Gitea review
      # got filed PENDING three times. Ships-and-documented is one commit, or red.
      - bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test
      - bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh
      # Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
      # and sandboxes a throwaway git repo, so it resolves no real credentials and
      # joins CI directly rather than the exclusions file.
      - bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
      # Hermetic regression for the git identity ladder (#1356): mock tea on PATH,
      # sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins
      # fail-closed: a seat whose login is missing gets a named error, never a
      # borrowed identity. Joins CI directly; its #1007 exclusion is burned down.
      - bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
      # Hermetic regression for issue-view.sh (#1357): mock tea/curl, sandboxed
      # repo. Pins that comment BODIES render on both paths and that a tea
      # failure is named as what it was (git-config vs credential).
      - bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
      # Hermetic behavioural regression for the PreToolUse wrapper guard: proves
      # it still blocks the three mistakes AND still lets reads, unwrapped
      # endpoints and ordinary commands through. Both directions are asserted —
      # a guard that over-blocks gets routed around, which fails just as hard.
      - bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh
      # Hermetic regression for mosaic-worktree.sh at fleet scale: stubs git onto
      # PATH so `list` faces ~450 KB of porcelain. The defect it pins is invisible
      # at small size — `git … | awk '…exit'` gives the producer SIGPIPE, which
      # under `set -euo pipefail` aborts the caller silently with rc=141 and no
      # output. A repo only reaches that once it has enough worktrees, so the
      # stub supplies the scale instead of the host's own checkout.
      - bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh

  # Canonical repo-structure declaration gate (T51 WP5c, spec §5.4 point 2):
  # .mosaic/repo.json is the machine-readable structure SSOT consumed by git
  # wrappers and the T32 gate seat; this is its repo-side CI enforcement.
  # Path-conditional: runs when the declaration, the vendored validator, or this
  # pipeline config changes (manual runs always include it). Fails the pipeline
  # on any VALIDATION_ERROR and enforces the schema_version 2 authoring rule
  # (--require-v2: edited/new declarations may not stay v1). The validator is
  # vendored into the framework tree (spec §5.1 final home) — provenance in its
  # header; the hostile-input suite (101 arms, hermetic) runs alongside so the
  # gate's own instrument ships in the same commit as the gate.
  structure-declaration:
    image: *node_image
    commands:
      - apk add --no-cache bash git
      # MOSAIC_HOST_ROOT is a runtime anchor (spec §1.2a: unset fails closed
      # for managed validation). CI has no host, so the step provisions an
      # EXPLICIT fixture root — honest configuration for the resolution path,
      # never a guess about a real host; the per-host containment checks are
      # runtime concerns and do not run against a fixture. Grammar, schema,
      # refs, flow, remote normalization, and path grammar all prove here.
      - mkdir -p /tmp/t51-ci-hostroot
      - bash packages/mosaic/framework/tools/structure/validate-repo-json.sh .mosaic/repo.json --require-v2
      - bash packages/mosaic/framework/tools/structure/test-validate-repo-json.sh
    environment:
      MOSAIC_HOST_ROOT: /tmp/t51-ci-hostroot
    when:
      - event: pull_request
        path:
          include:
            - '.mosaic/repo.json'
            - 'packages/mosaic/framework/tools/structure/**'
            - '.woodpecker/ci.yml'
      - event: manual

  # Canonical verify:release stage `upgrade-guard`.
  # Blocking gate (#791): a framework upgrade must never write or delete an
  # operator-owned path. The HARD GATE proves an unanticipated operator sentinel
  # survives a keep-mode reseed byte-identical (with rsync present AND absent —
  # keep mode is a single cp-based path that must not depend on rsync), and that a
  # corrupt/empty/missing manifest aborts fail-closed leaving operator files
  # untouched (B2/B3). The rollback gate proves a mid-sync failure is rolled back
  # from the pre-update snapshot (B1). The durable-snapshot gate (#791 PR2) proves
  # the retained, operator-scoped pre-update backup is taken before any mutation
  # (0700/0600, secret never logged, retention-pruned) and that the post-sync
  # verify net restores any operator file a manifest bug lets the sync touch. The
  # migration matrix pins the v2→v3 contract-file semantics. Pure bash, no
  # node_modules — runs early alongside sanitization.
  upgrade-guard:
    image: *node_image
    commands:
      - apk add --no-cache bash rsync
      - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh
      - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh
      - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
      - bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh

  # Canonical verify:release stage `typecheck` — the same `pnpm typecheck`
  # invocation (which runs the checkout preflight first, then turbo).
  typecheck:
    image: *node_image
    commands:
      - *enable_pnpm
      - pnpm typecheck
    depends_on:
      - install
      - sanitization
      - upgrade-guard

  # lint, format, and test are independent — run in parallel after typecheck.
  # Each runs exactly its canonical verify:release stage command.
  lint:
    image: *node_image
    commands:
      - *enable_pnpm
      - pnpm lint
    depends_on:
      - typecheck

  format:
    image: *node_image
    commands:
      - *enable_pnpm
      - pnpm format:check
    depends_on:
      - typecheck

  # Canonical verify:release stage `test` — the `pnpm test` line below is the
  # shared command; everything else in this step is PIPELINE-LEVEL
  # prerequisite the canonical command expects its caller to provide (SDLC-D-034):
  # the ci-postgres service + pg_isready wait + db:migrate (postgres path),
  # `apk add openssl`, and the pinned pi install. None of those can move into
  # the runner (it must also work locally on the PGlite path with no database).
  test:
    image: *node_image
    environment:
      # Avoid the namespace-level Woodpecker DB service named "postgres".
      # The Kubernetes backend exposes service containers by step name.
      DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
    commands:
      - *enable_pnpm
      # openssl (#912) is the wake HMAC signer: the digest H1/H2, beacon B12,
      # and install I8 legs hard-require it in CI. It is baked into ci-base via
      # Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this
      # `apk add` guarantees openssl is present on PR pipelines too (and is a
      # fast no-op once the rebuilt image already ships it).
      - apk add --no-cache openssl
      # Pi runtime (Invariant R): invariant_r_unittest.py hard-requires an
      # installed `pi` binary at exactly this measured version — the test
      # boots Pi's real tool registry to prove the read-only carve-out
      # resolves to real, unshadowed builtins, and fails loud (by design)
      # when the runtime is absent or drifts. The canonical Pi is
      # @earendil-works/pi-coding-agent@0.84.1 exactly (@mariozechner/* is
      # embedded-legacy). Step-level install because ci-base image publishes
      # are currently blocked on registry auth; fold into Dockerfile.ci once
      # that is fixed, keeping this as a fast no-op guard.
      - npm install -g @earendil-works/pi-coding-agent@0.84.1
      # postgresql-client (pg_isready) is baked into ci-base.
      # Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
      - |
        ready=0
        for i in $(seq 1 60); do
          if pg_isready -h ci-postgres -p 5432 -U mosaic; then
            ready=1
            break
          fi
          echo "Waiting for ci-postgres ($i/60)..."
          sleep 1
        done
        if [ "$ready" -ne 1 ]; then
          echo "ci-postgres did not become ready" >&2
          exit 1
        fi
      # Run migrations (DATABASE_URL is set in environment above)
      - pnpm --filter @mosaicstack/db run db:migrate
      # Run all tests
      - pnpm test
    depends_on:
      - typecheck

services:
  ci-postgres:
    image: pgvector/pgvector:pg17
    environment:
      POSTGRES_USER: mosaic
      POSTGRES_PASSWORD: mosaic
      POSTGRES_DB: mosaic
