# ---------------------------------------------------------------------------
# Build & Publish — GitLab MCP Server
#
# Branch build model:
#   - main push: always build → tag: sha-<short> + latest
#   - branch push with [build] or [e2e] in commit message: build → tag: <branch>-<sha7>
#   - tag push (v*): build → semver + latest + Helm chart
#   - PR: validate only (lint, helm template)
# ---------------------------------------------------------------------------
name: "Build & Publish"

on:
  push:
    branches: ["**"]
    tags: ["v*"]
  pull_request:
    branches: [main]

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
  CHART_NAME: gitlab-mcp

permissions:
  contents: read
  packages: write
  # NOTE: `id-token: write` and `attestations: write` are scoped to the
  # `docker` job only (principle of least privilege). Other jobs do not
  # interact with Sigstore/OIDC and inherit only contents+packages.

jobs:
  # -------------------------------------------------------------------
  # Job 1 — Validate (runs on every push and PR)
  # -------------------------------------------------------------------
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Lint Dockerfile
        uses: hadolint/hadolint-action@v3.3.0
        with:
          dockerfile: Dockerfile

      - name: Install Helm
        uses: azure/setup-helm@v5

      - name: Helm lint
        run: |
          helm lint chart/ \
            --set secret.GITLAB_PERSONAL_ACCESS_TOKEN=dummy-for-lint

      - name: Helm template smoke test
        run: |
          helm template test-release chart/ \
            --set secret.GITLAB_PERSONAL_ACCESS_TOKEN=dummy-for-lint \
            > /dev/null

      - name: Negative tests — fail-loud guards
        run: |
          set +e
          FAIL=0
          run_negative() {
            local name="$1"; local expected="$2"; shift 2
            OUT=$(helm template t chart/ "$@" 2>&1)
            RC=$?
            if [[ $RC -eq 0 ]]; then
              echo "::error::$name: expected failure, got success"
              FAIL=1; return
            fi
            if ! grep -qi "$expected" <<< "$OUT"; then
              echo "::error::$name: expected message '$expected' not in output"
              echo "::error::$name: actual output: $OUT"
              FAIL=1; return
            fi
            echo "::notice::$name OK"
          }

          # Guard 1: empty PAT in pat mode without existingSecret
          run_negative "empty-pat-token" "GITLAB_PERSONAL_ACCESS_TOKEN is required when AUTH_MODE=pat" \
            --set config.AUTH_MODE=pat \
            --set config.HOST=127.0.0.1 \
            --set secret.GITLAB_PERSONAL_ACCESS_TOKEN=""

          # Guard 2: PDB minAvailable >= replicaCount (deadlock)
          run_negative "pdb-deadlock" "would deadlock node drains" \
            --set secret.GITLAB_PERSONAL_ACCESS_TOKEN=ok \
            --set podDisruptionBudget.enabled=true \
            --set podDisruptionBudget.minAvailable=1 \
            --set podDisruptionBudget.maxUnavailable=null \
            --set replicaCount=1

          # Guard 3: both existingSecret and inline token
          run_negative "secret-conflict" "Set EITHER existingSecret OR secret.GITLAB_PERSONAL_ACCESS_TOKEN" \
            --set existingSecret=my-secret \
            --set secret.GITLAB_PERSONAL_ACCESS_TOKEN=tok

          # Guard 4: PDB both minAvailable and maxUnavailable
          run_negative "pdb-both-fields" "set EITHER minAvailable OR maxUnavailable" \
            --set secret.GITLAB_PERSONAL_ACCESS_TOKEN=ok \
            --set podDisruptionBudget.enabled=true \
            --set podDisruptionBudget.minAvailable=1 \
            --set podDisruptionBudget.maxUnavailable=1

          # Guard 5: invalid AUTH_MODE (rejected by values.schema.json)
          run_negative "invalid-auth-mode" "values don't meet the specifications" \
            --set secret.GITLAB_PERSONAL_ACCESS_TOKEN=ok \
            --set config.AUTH_MODE=invalid

          # Guard 6: PAT mode on non-loopback host (GHSA-8jr5-6gvj-rfpf)
          run_negative "pat-non-loopback" "AUTH_MODE=pat requires HOST" \
            --set config.AUTH_MODE=pat \
            --set config.HOST=0.0.0.0 \
            --set secret.GITLAB_PERSONAL_ACCESS_TOKEN=ok

          if [[ $FAIL -ne 0 ]]; then exit 1; fi

      - name: Install helm-docs
        run: |
          curl -sL https://github.com/norwoodj/helm-docs/releases/download/v1.14.2/helm-docs_1.14.2_Linux_x86_64.tar.gz \
            | tar xz -C /usr/local/bin helm-docs

      - name: Check README drift (helm-docs)
        run: |
          helm-docs --chart-search-root chart/ --output-file README.md
          git diff --exit-code chart/README.md || {
            echo "::error::chart/README.md is out of date. Run 'helm-docs --chart-search-root chart/ --output-file README.md' and commit."
            exit 1
          }

      - name: Tool E2E coverage gate
        run: ./scripts/check-tool-coverage.sh

  # -------------------------------------------------------------------
  # Job 2 — Docker build + push
  #
  # Runs when:
  #   - Push to main (always)
  #   - Push to branch with [build] or [e2e] in commit message
  #   - Tag push (v*)
  # Skipped on PRs and branch pushes without keyword.
  #
  # Risk-tier action pinning (active in this job only):
  #   This job is granted `id-token: write` (job-level, below), permitting
  #   OIDC token minting against our Fulcio identity. A compromised
  #   major-tag on any action that runs here could mint a signature
  #   against `^https://github.com/yoda-digital/mcp-gitlab-server`.
  #
  #   Policy:
  #     - Every action in this job → SHA-pin (immutable)
  #     - Other jobs (validate, helm) → major-tag (no id-token, Dependabot autobumps)
  #
  #   Dependabot still updates SHA-pinned actions; the comment after the
  #   SHA gives the human-readable version for review alongside the bump.
  # -------------------------------------------------------------------
  docker:
    runs-on: ubuntu-latest
    needs: validate
    permissions:
      contents: read
      packages: write
      id-token: write       # cosign keyless OIDC signing
      attestations: write   # SBOM + provenance attestations
    if: |
      github.event_name != 'pull_request' && (
        github.ref == 'refs/heads/main' ||
        github.ref_type == 'tag' ||
        contains(github.event.head_commit.message, '[build]') ||
        contains(github.event.head_commit.message, '[e2e]')
      )
    outputs:
      image-tag: ${{ steps.tags.outputs.primary }}
      image-full: ${{ steps.tags.outputs.full }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Compute image tags
        id: tags
        run: |
          SHA7="${GITHUB_SHA::7}"

          if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
            # Tag push: v1.2.3 → 1.2.3
            VERSION="${GITHUB_REF_NAME#v}"
            PRIMARY="$VERSION"
            TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
          elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
            # Main branch
            PRIMARY="sha-${SHA7}"
            TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${SHA7},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
          else
            # Feature branch: sanitize branch name
            BRANCH="${GITHUB_REF_NAME}"
            BRANCH_SAFE=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9._-]/-/g' | cut -c1-50)
            PRIMARY="${BRANCH_SAFE}-${SHA7}"
            TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${PRIMARY}"
          fi

          echo "primary=$PRIMARY" >> "$GITHUB_OUTPUT"
          echo "full=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${PRIMARY}" >> "$GITHUB_OUTPUT"
          echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
          echo "📦 Image tags: $TAGS"

      - name: Set up QEMU (multi-arch emulation)
        uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
        with:
          platforms: arm64

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4

      - name: Log in to ghcr.io
        uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          provenance: mode=max
          sbom: true
          tags: ${{ steps.tags.outputs.tags }}
          labels: |
            org.opencontainers.image.revision=${{ github.sha }}
            org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
          cache-from: type=gha,scope=main
          cache-to: type=gha,mode=max,scope=main

      - name: Trivy vulnerability scan
        # Tag releases (v*) fail on HIGH/CRITICAL; main + branch builds report-only
        # so transitive vulns land via Dependabot instead of blocking development.
        # Allow-list via .trivyignore at repo root (escape hatch for stale-DB blocking
        # of a CVE-fix release).
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: ${{ steps.tags.outputs.full }}
          severity: 'HIGH,CRITICAL'
          exit-code: ${{ github.ref_type == 'tag' && '1' || '0' }}
          format: 'table'
          ignore-unfixed: true
          vuln-type: 'os,library'
          trivyignores: .trivyignore

      - name: Install cosign
        uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3

      - name: Sign image with cosign (keyless OIDC)
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          cosign sign --yes \
            "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}"

      - name: Verify cosign signature (CI smoke with Rekor lag tolerance)
        # Sign + verify in the same job can race against Rekor's inclusion log
        # (10-30s lag in pathological cases). We retry 5×15s, then:
        #   - tag pushes: hard-fail (release trust boundary)
        #   - main / branch pushes: soft-warn (don't block development)
        # Every outcome is also written to GITHUB_STEP_SUMMARY so soft-warns
        # are grep-able post-hoc, not just ephemeral ::warning:: annotations.
        # See docs/OPERATIONS.md § "Sigstore outage — temporary release path"
        # for the documented bypass when Sigstore is impaired.
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          REF="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}"
          IDENTITY_RE='^https://github\.com/yoda-digital/mcp-gitlab-server/\.github/workflows/build\.yml@refs/'
          ISSUER='https://token.actions.githubusercontent.com'
          verify_quiet() {
            cosign verify \
              --certificate-identity-regexp "$IDENTITY_RE" \
              --certificate-oidc-issuer "$ISSUER" \
              "$REF" > /dev/null 2>&1
          }
          verify_loud() {
            # Final attempt: leave stderr visible so a real signing regression
            # (e.g. identity-regex drift after workflow move) is diagnosable
            # from the job log without a local repro.
            cosign verify \
              --certificate-identity-regexp "$IDENTITY_RE" \
              --certificate-oidc-issuer "$ISSUER" \
              "$REF"
          }
          for attempt in 1 2 3 4; do
            if verify_quiet; then
              echo "::notice::cosign verify OK on attempt $attempt for $REF"
              printf '## cosign verify\n\n- ✅ OK (attempt %s)\n- Image: `%s`\n' "$attempt" "$REF" >> "$GITHUB_STEP_SUMMARY"
              exit 0
            fi
            echo "::warning::cosign verify attempt $attempt failed (Rekor inclusion lag?); retrying in 15s"
            sleep 15
          done
          # 5th attempt: loud — operator sees the actual cosign error if it persists
          if verify_loud; then
            echo "::notice::cosign verify OK on attempt 5 for $REF"
            printf '## cosign verify\n\n- ✅ OK (attempt 5, after Rekor lag)\n- Image: `%s`\n' "$REF" >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi
          if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
            echo "::error::cosign verify failed after 5 attempts on tag release ($GITHUB_REF_NAME); refusing to publish unverified release"
            printf '## cosign verify\n\n- ❌ FAILED after 5 attempts on tag `%s`\n- Image: `%s`\n- Action: release blocked; see job log for cosign error\n' "$GITHUB_REF_NAME" "$REF" >> "$GITHUB_STEP_SUMMARY"
            exit 1
          fi
          echo "::warning::cosign verify failed after 5 attempts on non-tag build; not blocking (image is signed; verification will succeed once Rekor catches up)"
          printf '## cosign verify\n\n- ⚠️ SOFT-WARN: failed 5×, non-tag build (`%s`)\n- Image: `%s`\n- Action: image is signed; re-verify locally in ~5min, or grep this summary for the pattern across recent runs.\n' "$GITHUB_REF_NAME" "$REF" >> "$GITHUB_STEP_SUMMARY"
          exit 0

      - name: Compute E2E image tags
        id: e2e-tags
        run: |
          PRIMARY="${{ steps.tags.outputs.primary }}"
          E2E_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-e2e"

          if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
            E2E_TAGS="${E2E_IMAGE}:${PRIMARY},${E2E_IMAGE}:latest"
          elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
            E2E_TAGS="${E2E_IMAGE}:${PRIMARY},${E2E_IMAGE}:latest"
          else
            E2E_TAGS="${E2E_IMAGE}:${PRIMARY}"
          fi

          echo "tags=$E2E_TAGS" >> "$GITHUB_OUTPUT"
          echo "📦 E2E image tags: $E2E_TAGS"

      - name: Build and push E2E image
        uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
        with:
          context: ./e2e
          file: ./e2e/Dockerfile
          push: true
          tags: ${{ steps.e2e-tags.outputs.tags }}
          labels: |
            org.opencontainers.image.revision=${{ github.sha }}
            org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
            org.opencontainers.image.description=E2E test runner for GitLab MCP Server
          cache-from: type=gha,scope=e2e
          cache-to: type=gha,mode=max,scope=e2e

  # -------------------------------------------------------------------
  # Job 3 — Helm package + push (only on tags)
  # -------------------------------------------------------------------
  helm:
    runs-on: ubuntu-latest
    needs: docker
    if: startsWith(github.ref, 'refs/tags/v')
    steps:
      - uses: actions/checkout@v6

      - name: Install Helm
        uses: azure/setup-helm@v5

      - name: Install yq
        uses: mikefarah/yq@v4

      - name: Log in to ghcr.io (Helm OCI)
        run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin

      - name: Compute version
        id: version
        run: |
          VERSION="${GITHUB_REF_NAME#v}"
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"

      - name: Update appVersion in Chart.yaml
        run: |
          VERSION="${{ steps.version.outputs.version }}"
          yq -i ".appVersion = \"$VERSION\"" chart/Chart.yaml
          # Verify mutation
          ACTUAL=$(yq '.appVersion' chart/Chart.yaml)
          if [[ "$ACTUAL" != "$VERSION" ]]; then
            echo "::error::appVersion mismatch: expected $VERSION, got $ACTUAL"
            exit 1
          fi

      - name: Package chart
        run: |
          VERSION="${{ steps.version.outputs.version }}"
          helm package chart/ \
            --version "$VERSION" \
            --destination .helm-pkg/

      - name: Push chart to ghcr.io
        run: |
          VERSION="${{ steps.version.outputs.version }}"
          helm push ".helm-pkg/${{ env.CHART_NAME }}-${VERSION}.tgz" \
            oci://${{ env.REGISTRY }}/yoda-digital/charts
