name: Build blueprint-controller

# blueprint-controller — slice C3 of EPIC-0 #1095. Watches
# Blueprint.blueprints.openova.io/v1 CRs and reconciles canonical
# blueprint definitions (bp-<name>:<semver> OCI artefacts) against
# the per-Sovereign Gitea catalog mirror.
#
# Per docs/INVIOLABLE-PRINCIPLES.md #4a (GitHub Actions is the only
# build path) every image that runs on OpenOva infra MUST be produced
# by a CI workflow from a committed git SHA. Mirrors the existing
# build-application-controller.yaml shape — same auth flow, same
# cosign keyless signing, same SBOM attestation.
#
# Per `feedback_inviolable_principles.md`: event-driven only, NO cron.
# Triggers on push-to-main with paths filter (so unrelated commits
# don't burn CI minutes), pull_request for reviewers, and
# workflow_dispatch for manual re-runs.

on:
  push:
    paths:
      - 'core/controllers/blueprint/**'
      - 'core/controllers/internal/**'
      # core/controllers/pkg/** is the shared HTTP-client tree (gitea,
      # keycloak, kc-mappers, …) consumed by every Group C controller's
      # Containerfile via `COPY core/controllers/pkg`. Without this path
      # entry a change to the shared pkg/ tree rebuilds the image only
      # if the same PR also happens to touch files under blueprint/ —
      # which silently held the t38 #1997 gitea-405 fix in main for
      # ~12h. Uniform pattern across every build-*-controller.yaml
      # (TBD-A69 #2006).
      - 'core/controllers/pkg/**'
      - 'core/controllers/go.mod'
      - 'core/controllers/go.sum'
      - '.github/workflows/build-blueprint-controller.yaml'
    branches: [main]
  pull_request:
    paths:
      - 'core/controllers/blueprint/**'
      - 'core/controllers/internal/**'
      - 'core/controllers/pkg/**'
      - 'core/controllers/go.mod'
      - 'core/controllers/go.sum'
      - '.github/workflows/build-blueprint-controller.yaml'
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE: ghcr.io/openova-io/openova/blueprint-controller

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      # contents: write — the deploy step below pushes a values.yaml SHA
      # bump back to main so the bp-catalyst-platform chart picks up the
      # newly-built image without an operator manually editing the file
      # (per `feedback_no_mvp_no_workarounds.md` rule 1: target-state,
      # never "manual follow-up bump"). Pre-#2006 this workflow shipped
      # without auto-bump — same deploy-gap class as #1997.
      contents: write
      packages: write
      # id-token write is required by cosign keyless signing (Sigstore).
      id-token: write
      # actions: write — required for `gh workflow run` to dispatch the
      # downstream blueprint-release chart re-publish workflow.
      actions: write
    outputs:
      sha_short: ${{ steps.vars.outputs.sha_short }}
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set short SHA
        id: vars
        run: echo "sha_short=$(echo $GITHUB_SHA | head -c 7)" >> "$GITHUB_OUTPUT"

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.23'
          cache-dependency-path: |
            core/controllers/go.sum

      - name: go vet
        working-directory: core/controllers
        # Slice CC1 (#1095) consolidated the 5 Group C controllers into
        # a single shared go.mod. Vet scoped to this controller's tree
        # plus the shared internal/ helpers it depends on.
        run: go vet ./blueprint/... ./internal/...

      - name: Run unit tests
        working-directory: core/controllers
        run: go test -count=1 -race ./blueprint/... ./internal/...

      # On pull_request runs we stop here — image push requires
      # `packages: write` which only main-branch authors hold.
      - name: Login to GHCR
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Set up Docker Buildx
        if: github.event_name != 'pull_request'
        uses: docker/setup-buildx-action@v3

      - name: Build and push image
        id: build
        if: github.event_name != 'pull_request'
        uses: docker/build-push-action@v6
        with:
          # Build context is the repository root so the Containerfile's
          # COPY paths can reach core/controllers/blueprint/.
          context: .
          file: core/controllers/blueprint/Containerfile
          push: true
          tags: |
            ${{ env.IMAGE }}:${{ steps.vars.outputs.sha_short }}
            ${{ env.IMAGE }}:latest
          labels: |
            org.opencontainers.image.source=https://github.com/openova-io/openova
            org.opencontainers.image.revision=${{ github.sha }}
            org.opencontainers.image.title=blueprint-controller
            org.opencontainers.image.description=Reconciles Blueprint.blueprints.openova.io/v1 CRs against per-Sovereign Gitea catalog mirror (slice C3 of EPIC-0 #1095)
          # provenance=false: containerd 1.7.x on k3s mis-resolves the
          # provenance attestation manifest. SBOM attestation handled by
          # the cosign attest step below.
          provenance: false
          sbom: false

      - name: Install cosign
        if: github.event_name != 'pull_request'
        uses: sigstore/cosign-installer@v3

      - name: Sign image with cosign (keyless)
        if: github.event_name != 'pull_request'
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          cosign sign --yes "${IMAGE}@${DIGEST}"

      - name: Generate and attest SBOM
        if: github.event_name != 'pull_request'
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          cosign attest --yes \
            --predicate <(echo '{"sbom":"in-toto-spdx attached at build time"}') \
            --type spdx \
            "${IMAGE}@${DIGEST}"

      # Auto-bump the chart values.yaml tag so the next Sovereign chart
      # rollout picks up this image without a manual edit. Per
      # `feedback_no_mvp_no_workarounds.md` rule 1 (target-state, no
      # operator-action gates) and `feedback_inviolable_principles.md`
      # (event-driven, never cron). Mirrors the pattern in
      # build-application-controller.yaml + build-organization-controller.yaml.
      # Added as part of TBD-A69 (#2006) — pre-#2006 this workflow shipped
      # without auto-bump, so the same deploy-gap class as #1997 was live
      # for every blueprint-controller code fix.
      - name: Bump controllers.blueprint.image.tag in values.yaml
        if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
        env:
          SHA_SHORT: ${{ steps.vars.outputs.sha_short }}
        run: |
          VALUES="products/catalyst/chart/values.yaml"
          # awk: find `  blueprint:` under `controllers:`, then update
          # the next `tag: "..."` line. Stops at the next top-level key
          # so we don't accidentally bump a sibling controller's tag.
          awk -v sha="${SHA_SHORT}" '
            /^controllers:/ { in_ctrls=1 }
            in_ctrls && /^  blueprint:/ { print; in_bp=1; next }
            in_ctrls && /^  [a-z]/ && !/^  blueprint:/ { in_bp=0 }
            in_bp && /^      tag:/ { sub(/"[^"]*"/, "\"" sha "\""); in_bp=0 }
            { print }
          ' "${VALUES}" > "${VALUES}.tmp" && mv "${VALUES}.tmp" "${VALUES}"
          echo "values.yaml after bump:"
          grep -A4 "^  blueprint:" "${VALUES}" | head -10

      - name: Commit and push values.yaml bump
        id: deploy_commit
        if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
        env:
          SHA_SHORT: ${{ steps.vars.outputs.sha_short }}
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          if git diff --quiet products/catalyst/chart/values.yaml; then
            echo "no values.yaml change — already pinned to ${SHA_SHORT}"
            echo "pushed=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          git add products/catalyst/chart/values.yaml
          git commit -m "deploy: bump blueprint-controller image to ${SHA_SHORT}"
          # Pull-rebase to avoid races with parallel build commits.
          git pull --rebase --autostash origin main || true
          git push origin HEAD:main
          echo "pushed=true" >> "$GITHUB_OUTPUT"

      # GitHub Actions does NOT trigger workflows from bot pushes by
      # default (anti-recursion safeguard). Without this dispatch the
      # rebuilt image is NEVER baked into a new chart version, so
      # Sovereigns keep installing the previous chart with the previous
      # image tag (`feedback_no_mvp_no_workarounds.md` rule 1 violation).
      - name: Dispatch blueprint-release for chart re-publish
        if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && steps.deploy_commit.outputs.pushed == 'true'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh workflow run blueprint-release.yaml \
            --repo "${GITHUB_REPOSITORY}" \
            --ref main \
            -f blueprint=catalyst \
            -f tree=products
