name: CI

# Orchestrates the CI pipeline from per-stage reusable workflows (ci-security / ci-test /
# ci-storybook / ci-build) so each stage lives in its own file. Order: preflight, then security +
# test (+ storybook), then build. All must pass before any deploy workflow is enabled (deploys
# trigger on this workflow's success; see deploy-*.yml), and the full pipeline runs:
# security -> test -> build -> deploy -> e2e (e2e.yml, post-deploy).
#
# The build stage is disabled for feature branches, EXCEPT those targeting main: it runs on a push
# to main and on any PR whose base is main - so nothing lands on main (the dev-deploy branch)
# without a green build. Issue PRs into a milestone branch skip build and get fast security + test
# feedback.
#
# Where the full proof runs: on a PR into main, every gate, unfiltered (the path filter is forced
# open there, as it is on main). The squash merge of an up-to-date milestone PR is byte-for-byte
# the tree that PR's run tested, so the push to main skips the test stage (the preflight tree
# lookup) and goes straight to build + deploy - the proof ran ahead of the human merge gate
# instead of repeating after it. Security still runs on every push: advisories change under an
# unchanged tree.
#
# Runner budget: every job waits for one of the two bloom-arc slots and pays ~15-40 s of set-up,
# so related checks share a job (preflight, the three test jobs, storybook) instead of each taking
# a dispatch of its own. Inside a shared job, each check runs even when an earlier one failed, so
# one red run still reports every result.

on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  # Two cheap classifications every other job keys on, in one dispatch:
  # - Has this exact tree already passed a full run? Every full run (push to main, PR into main)
  #   records the tree it tests as the artifact tested-tree-<tree> before anything else runs; a
  #   path-filtered run (a PR into a milestone branch) records nothing, since it did not test
  #   everything. The lookup (scripts/ci/tested-twin.mjs) counts a run as proof only once it has
  #   concluded success, and any doubt (an API error) answers "run everything".
  # - Which gates can this diff affect? (.github/actions/changes, #788.) Gated via needs + if,
  #   never top-level `on: paths:` (a check skipped by a top-level path filter never reports).
  preflight:
    name: Preflight (tree lookup, path filter)
    runs-on: bloom-arc
    timeout-minutes: 5
    permissions:
      actions: read
      contents: read
      pull-requests: read
    outputs:
      skip: ${{ steps.lookup.outputs.skip }}
      web: ${{ steps.changes.outputs.web }}
      ui: ${{ steps.changes.outputs.ui }}
      infra: ${{ steps.changes.outputs.infra }}
      docs: ${{ steps.changes.outputs.docs }}
      landing: ${{ steps.changes.outputs.landing }}
    env:
      FULL_RUN: ${{ github.event_name == 'push' || github.base_ref == 'main' }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Unit-test the lookup
        run: node --test scripts/ci/tested-twin.test.mjs scripts/ci/r2-marker.test.mjs

      - name: Set up private R2 artifact lookup
        uses: hidden-claw/actions-r2/setup@c9ede64f3969d05d137c055a5d8339a9db7cabd7
      - id: lookup
        run: node scripts/ci/tested-twin.mjs
        env:
          GITHUB_TOKEN: ${{ github.token }}
          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 }}

      - name: Record the tree this full run tests
        if: ${{ env.FULL_RUN == 'true' }}
        env:
          TREE: ${{ steps.lookup.outputs.tree }}
        run: printf 'tree %s\nrun %s\ncommit %s\n' "$TREE" "$GITHUB_RUN_ID" "$GITHUB_SHA" > tested-tree.txt

      - 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 }}
        if: ${{ env.FULL_RUN == 'true' }}
        with:
          name: tested-tree-${{ steps.lookup.outputs.tree }}
          path: tested-tree.txt
          retention-days: 14

      - id: changes
        name: Classify changed paths
        uses: ./.github/actions/changes

  security:
    uses: ./.github/workflows/ci-security.yml

  test:
    needs: preflight
    if: ${{ needs.preflight.outputs.skip != 'true' }}
    uses: ./.github/workflows/ci-test.yml
    secrets:
      R2_ARTIFACTS_ACCESS_KEY_ID: ${{ secrets.R2_ARTIFACTS_ACCESS_KEY_ID }}
      R2_ARTIFACTS_SECRET_ACCESS_KEY: ${{ secrets.R2_ARTIFACTS_SECRET_ACCESS_KEY }}
    with:
      web: ${{ needs.preflight.outputs.web }}
      infra: ${{ needs.preflight.outputs.infra }}
      docs: ${{ needs.preflight.outputs.docs }}
      landing: ${{ needs.preflight.outputs.landing }}

  # The @bloom/ui Storybook: static build, Chromatic visual regression, hosted preview. Not
  # skipped by the tree lookup - a push to main publishes the stable Storybook alias, and
  # Chromatic stays strict there.
  storybook:
    needs: preflight
    if: ${{ needs.preflight.outputs.ui == 'true' }}
    permissions:
      contents: read
      pull-requests: write
    uses: ./.github/workflows/ci-storybook.yml
    secrets:
      CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

  # Changelog-fragment gate (#518): every feature PR into main must add a changelog.d/
  # fragment (or opt out with an empty +misc fragment) so CHANGELOG.md is assembled from
  # per-PR files instead of top-inserts. Runs only on PRs whose base is main - not on the
  # push/merge to main, which carries no fragment.
  changelog:
    if: ${{ github.event_name == 'pull_request' && github.base_ref == 'main' }}
    uses: ./.github/workflows/ci-changelog.yml

  # Runs after a green security + test stage, or after a test stage the tree lookup skipped;
  # never after a failure or a cancellation.
  build:
    needs: [preflight, security, test]
    if: >-
      ${{ !cancelled() &&
          needs.preflight.result == 'success' &&
          needs.security.result == 'success' &&
          (needs.test.result == 'success' || needs.test.result == 'skipped') &&
          (github.ref == 'refs/heads/main' || github.base_ref == 'main') }}
    # packages: write lets the build stage push the SHA-tagged API image to GHCR on a push to main
    # (build-once, deploy-many). PRs build the image to validate but do not push.
    permissions:
      contents: read
      packages: write
    uses: ./.github/workflows/ci-build.yml
