name: CI

on:
  pull_request:
    branches:
      - develop
  push:
    branches:
      - develop
    tags:
      - "v*"

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}

permissions:
  contents: read

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: rise-deploy/rise
  BUILDER_IMAGE_NAME: rise-deploy/rise-builder
  CHART_REGISTRY: ghcr.io/rise-deploy
  NODE_VERSION: "24"
  # renovate: datasource=github-tags depName=rust packageName=rust-lang/rust extractVersion=^(?<version>\d+\.\d+\.\d+)$
  RUST_VERSION: "1.95.0"

jobs:
  prepare:
    name: Prepare CI metadata
    runs-on: ubuntu-latest
    outputs:
      ci_version: ${{ steps.meta.outputs.ci_version }}
      image_tag: ${{ steps.meta.outputs.image_tag }}
      short_sha: ${{ steps.meta.outputs.short_sha }}
      is_trusted_pr: ${{ steps.meta.outputs.is_trusted_pr }}
      should_publish: ${{ steps.meta.outputs.should_publish }}
    steps:
      - name: Compute metadata
        id: meta
        shell: bash
        run: |
          set -euo pipefail

          short_sha="${GITHUB_SHA::7}"
          if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
            pr_number="${{ github.event.pull_request.number }}"
            ci_version="0.0.0-pr.${pr_number}+${short_sha}"
            image_tag="pr-${pr_number}-${short_sha}"
            is_trusted_pr="false"
            if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
              is_trusted_pr="true"
            fi
          elif [[ "${GITHUB_REF}" == refs/heads/develop ]]; then
            ci_version="0.0.0-develop.${GITHUB_RUN_NUMBER}+${short_sha}"
            image_tag="develop-${short_sha}"
            is_trusted_pr="false"
          elif [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
            version="${GITHUB_REF#refs/tags/v}"
            ci_version="${version}"
            image_tag="${version}"
            is_trusted_pr="false"
          else
            ci_version="0.0.0-dev.${GITHUB_RUN_NUMBER}+${short_sha}"
            image_tag="dev-${short_sha}"
            is_trusted_pr="false"
          fi

          should_publish="false"
          if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
            should_publish="true"
          elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" && "${is_trusted_pr}" == "true" ]]; then
            should_publish="true"
          fi

          echo "ci_version=${ci_version}" >> "$GITHUB_OUTPUT"
          echo "image_tag=${image_tag}" >> "$GITHUB_OUTPUT"
          echo "short_sha=${short_sha}" >> "$GITHUB_OUTPUT"
          echo "is_trusted_pr=${is_trusted_pr}" >> "$GITHUB_OUTPUT"
          echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT"

          echo "CI Version: ${ci_version}"
          echo "Image Tag: ${image_tag}"
          echo "Short SHA: ${short_sha}"
          echo "Is Trusted PR: ${is_trusted_pr}"
          echo "Should Publish: ${should_publish}"

  rust-quality:
    name: Rust quality checks
    runs-on: ubuntu-latest
    needs: prepare
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Set up Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: ${{ env.RUST_VERSION }}
          components: rustfmt, clippy

      - name: Rust cache
        uses: Swatinem/rust-cache@v2

      - name: Setup mise
        uses: jdx/mise-action@v4
        with:
          cache: true
          experimental: true

      - name: Install mise tools
        run: mise use -g cargo:sqlx-cli cargo:cargo-all-features

      - name: Install cargo-audit
        uses: taiki-e/install-action@v2
        with:
          tool: cargo-audit

      - name: Verify backend config schema is up to date
        run: mise config:schema:check

      - name: Verify rise.toml schema is up to date
        run: mise rise-toml:schema:check

      - name: Verify resource API schemas are up to date
        run: mise resource:schema:check

      - name: Verify CRDs are up to date
        run: mise crd:check

      - name: Check formatting
        run: cargo fmt --all -- --check

      - name: Run Clippy (all feature combinations)
        run: cargo all-features clippy --all-targets -- -D warnings
        env:
          SQLX_OFFLINE: true

      - name: Run Clippy (resource API crate)
        run: cargo clippy --package rise-resource-api --all-targets -- -D warnings

      - name: Run cargo audit
        run: cargo audit

  unit-tests-linux:
    name: Linux tests
    runs-on: ubuntu-latest
    needs: prepare
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: rise_test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Set up Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: ${{ env.RUST_VERSION }}

      - name: Rust cache
        uses: Swatinem/rust-cache@v2

      - name: Setup mise
        uses: jdx/mise-action@v4
        with:
          cache: true
          experimental: true

      - name: Install mise tools
        run: mise use -g cargo:sqlx-cli

      - name: Run database migrations
        run: |
          sqlx database create
          sqlx migrate run
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/rise_test

      - name: Verify SQLX offline query data is in sync with schema
        # Runs here (not in rust-quality) because sqlx-cli 0.9+ requires a
        # live DB for `prepare --check` to detect schema drift. Coverage
        # drift (missing cache entry for a new query!() macro) is still
        # caught by the SQLX_OFFLINE=true clippy step in rust-quality.
        run: mise run sqlx:check
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/rise_test

      - name: Run tests
        run: cargo test --workspace --all-features
        env:
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/rise_test

  helm-lint:
    name: Helm lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Setup mise
        uses: jdx/mise-action@v4
        with:
          cache: true
          experimental: true

      - name: Install mise tools
        run: mise use -g helm

      - name: Verify vendored chart dependencies
        shell: bash
        env:
          HELM_REPOSITORY_CONFIG: ${{ runner.temp }}/helm-repos.yaml
          HELM_REPOSITORY_CACHE: ${{ runner.temp }}/helm-cache
        run: |
          set -euo pipefail
          helm repo add grafana https://grafana.github.io/helm-charts
          helm dependency build helm/rise
          # Helm v4's Chart.lock only stores one aggregate digest over the
          # dependencies block, not per-tarball bytes — `helm dependency
          # build` would re-use a corrupted-but-correctly-named tarball
          # already in helm/rise/charts/. Pin the bytes explicitly against
          # helm/rise/charts.sha256.
          if [ ! -f helm/rise/charts.sha256 ]; then
            echo "ERROR: helm/rise/charts.sha256 is missing; cannot verify vendored tarballs." >&2
            exit 1
          fi
          ( cd helm/rise/charts && shasum -a 256 -c ../charts.sha256 ) || {
            echo "ERROR: vendored chart tarball digest mismatch against helm/rise/charts.sha256." >&2
            echo "Either the tarball was tampered with, or the upstream chart was re-released." >&2
            echo "If legitimate, regenerate the manifest:" >&2
            echo "  (cd helm/rise/charts && shasum -a 256 *.tgz) > helm/rise/charts.sha256" >&2
            exit 1
          }
          # Also check there are no extra/unexpected tarballs in charts/.
          expected="$(awk '!/^#/ && NF>=2 {print $2}' helm/rise/charts.sha256 | sort)"
          actual="$(cd helm/rise/charts && ls *.tgz 2>/dev/null | sort)"
          if [ "${expected}" != "${actual}" ]; then
            echo "ERROR: helm/rise/charts/ tarballs do not match helm/rise/charts.sha256 manifest." >&2
            diff <(printf '%s\n' "${expected}") <(printf '%s\n' "${actual}") || true
            exit 1
          fi
          git diff --exit-code -- helm/rise/Chart.lock helm/rise/charts

      - name: Set up chart-testing
        uses: helm/chart-testing-action@v2.8.0

      - name: Add chart repositories
        run: helm repo add grafana https://grafana.github.io/helm-charts

      - name: Run chart-testing (lint)
        run: ct lint --target-branch ${{ github.event.repository.default_branch }} --charts helm/rise --validate-maintainers=false

      - name: Install kubeconform
        run: |
          set -euo pipefail
          curl -sSLo /tmp/kubeconform.tar.gz https://github.com/yannh/kubeconform/releases/download/v0.7.0/kubeconform-linux-amd64.tar.gz
          tar -xzf /tmp/kubeconform.tar.gz -C /tmp kubeconform
          sudo install -m 0755 /tmp/kubeconform /usr/local/bin/kubeconform

      - name: Validate Helm templates with kubeconform
        run: |
          helm template rise helm/rise | kubeconform -strict -summary -skip CompositeController
          helm template rise helm/rise -f helm/rise/values-ci.yaml | kubeconform -strict -summary -skip CompositeController

  package-image:
    name: Build and publish image
    runs-on: ubuntu-latest
    needs:
      - prepare
      - rust-quality
      - unit-tests-linux
      - helm-lint
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

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

      - name: Log in to GHCR
        if: needs.prepare.outputs.should_publish == 'true'
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and optionally push image
        uses: docker/build-push-action@v7
        with:
          context: .
          file: ./Dockerfile
          target: rise
          push: ${{ needs.prepare.outputs.should_publish == 'true' }}
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.image_tag }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ needs.prepare.outputs.short_sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64
          provenance: false

      - name: Build and optionally push builder image
        uses: docker/build-push-action@v7
        with:
          context: .
          file: ./Dockerfile
          target: rise-builder
          push: ${{ needs.prepare.outputs.should_publish == 'true' }}
          tags: |
            ${{ env.REGISTRY }}/${{ env.BUILDER_IMAGE_NAME }}:${{ needs.prepare.outputs.image_tag }}
            ${{ env.REGISTRY }}/${{ env.BUILDER_IMAGE_NAME }}:sha-${{ needs.prepare.outputs.short_sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64
          provenance: false

  package-helm:
    name: Package and publish Helm chart
    runs-on: ubuntu-latest
    needs:
      - prepare
      - package-image
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Setup mise
        uses: jdx/mise-action@v4
        with:
          cache: true
          experimental: true

      - name: Install mise tools
        run: mise use -g helm

      - name: Patch chart version and appVersion for CI package
        shell: bash
        run: |
          set -euo pipefail
          ci_version="${{ needs.prepare.outputs.ci_version }}"
          image_tag="${{ needs.prepare.outputs.image_tag }}"
          sed -i "s/^name: .*/name: rise-helm/" helm/rise/Chart.yaml
          sed -i "s/^version: .*/version: ${ci_version}/" helm/rise/Chart.yaml
          sed -i "s/^appVersion: .*/appVersion: \"${image_tag}\"/" helm/rise/Chart.yaml
          cat helm/rise/Chart.yaml

      - name: Package chart
        run: |
          mkdir -p .helm-packages
          helm package helm/rise -d .helm-packages

      - name: Upload chart artifact
        uses: actions/upload-artifact@v5
        with:
          name: helm-chart-${{ needs.prepare.outputs.image_tag }}
          path: .helm-packages/*.tgz

      - name: Login to GHCR for Helm OCI push
        if: needs.prepare.outputs.should_publish == 'true'
        run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ${{ env.REGISTRY }} -u "${{ github.actor }}" --password-stdin

      - name: Push chart to OCI registry
        if: needs.prepare.outputs.should_publish == 'true'
        run: |
          chart_file=$(ls .helm-packages/*.tgz)
          helm push "$chart_file" "oci://${{ env.CHART_REGISTRY }}"

  docs-deploy:
    name: Deploy docs
    runs-on: ubuntu-latest
    if: github.event_name == 'push'
    needs: prepare
    # Pages deploys must be serialized across docs-deploy, docs-preview, and
    # docs-preview-cleanup — concurrent deploys race the atomic site swap and
    # whichever finishes last wipes the others' sibling subtrees (e.g. PR
    # previews disappearing after a develop merge).
    concurrency:
      group: pages-deploy
      cancel-in-progress: false
    environment:
      name: github-pages
    permissions:
      actions: read
      contents: read
      id-token: write
      pages: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
          cache-dependency-path: docs/package-lock.json

      - name: Install docs dependencies
        working-directory: docs
        run: npm ci

      - name: Build user docs
        working-directory: docs/user
        env:
          ASTRO_BASE: /${{ github.event.repository.name }}/user
          RISE_DOCS_SELECTOR_URL: /${{ github.event.repository.name }}/
          RISE_DOCS_SWITCH_URL: /${{ github.event.repository.name }}/operator/
          RISE_DOCS_SWITCH_LABEL: Operator docs
        run: npm run build

      - name: Build operator docs
        working-directory: docs/engineering
        env:
          ASTRO_BASE: /${{ github.event.repository.name }}/operator
          RISE_DOCS_SELECTOR_URL: /${{ github.event.repository.name }}/
          RISE_DOCS_SWITCH_URL: /${{ github.event.repository.name }}/user/
          RISE_DOCS_SWITCH_LABEL: User docs
        run: npm run build

      - name: Seed Pages site with current live state
        uses: ./.github/actions/seed-pages-site
        with:
          output-dir: docs-prod
          github-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Stage docs
        shell: bash
        run: |
          set -euo pipefail
          repo_name="${{ github.event.repository.name }}"
          # Replace only the prod-owned subtrees; anything else seeded in
          # docs-prod/ (notably preview/pr-*) must survive this atomic deploy.
          rm -rf docs-prod/user docs-prod/operator docs-prod/index.html
          mkdir -p docs-prod/user docs-prod/operator
          cp -a docs/user/dist/. docs-prod/user/
          cp -a docs/engineering/dist/. docs-prod/operator/
          cat > docs-prod/index.html <<HTML
          <!doctype html>
          <html lang="en">
            <head>
              <meta charset="utf-8">
              <meta name="viewport" content="width=device-width, initial-scale=1">
              <title>Rise Documentation</title>
              <style>
                :root {
                  color-scheme: light dark;
                  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
                  background: #111827;
                  color: #f9fafb;
                }
                body {
                  margin: 0;
                  min-height: 100vh;
                  display: grid;
                  place-items: center;
                }
                main {
                  width: min(100% - 32px, 760px);
                }
                h1 {
                  margin: 0 0 12px;
                  font-size: 36px;
                  line-height: 1.1;
                }
                p {
                  margin: 0 0 28px;
                  color: #cbd5e1;
                  font-size: 17px;
                }
                .choices {
                  display: grid;
                  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
                  gap: 16px;
                }
                a {
                  display: block;
                  padding: 20px;
                  border: 1px solid #334155;
                  border-radius: 8px;
                  background: #1f2937;
                  color: inherit;
                  text-decoration: none;
                }
                a:hover {
                  border-color: #38bdf8;
                }
                strong {
                  display: block;
                  margin-bottom: 8px;
                  font-size: 20px;
                }
                span {
                  color: #cbd5e1;
                }
              </style>
            </head>
            <body>
              <main>
                <h1>Rise Documentation</h1>
                <p>Choose which documentation set to read.</p>
                <div class="choices">
                  <a href="/${repo_name}/user/">
                    <strong>Rise User Docs</strong>
                    <span>Application deployment, CLI usage, environments, service accounts, and project extensions.</span>
                  </a>
                  <a href="/${repo_name}/operator/">
                    <strong>Rise Operator Docs</strong>
                    <span>Platform operation, Kubernetes setup, configuration, registries, and production guidance.</span>
                  </a>
                </div>
              </main>
            </body>
          </html>
          HTML

      - name: Configure GitHub Pages
        uses: actions/configure-pages@v5

      - name: Upload docs artifact
        uses: actions/upload-pages-artifact@v4
        with:
          path: docs-prod

      - name: Deploy docs to GitHub Pages
        uses: actions/deploy-pages@v4

  docs-preview:
    name: Deploy docs preview
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request' && needs.prepare.outputs.is_trusted_pr == 'true'
    needs: prepare
    # Shared serialization queue with docs-deploy and docs-preview-cleanup; see
    # the docs-deploy concurrency block for why.
    concurrency:
      group: pages-deploy
      cancel-in-progress: false
    environment:
      name: github-pages
    permissions:
      actions: read
      contents: read
      id-token: write
      issues: write
      pages: write
      pull-requests: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
          cache-dependency-path: docs/package-lock.json

      - name: Install docs dependencies
        working-directory: docs
        run: npm ci

      - name: Build user docs preview
        working-directory: docs/user
        env:
          ASTRO_BASE: /${{ github.event.repository.name }}/preview/pr-${{ github.event.pull_request.number }}/user
          RISE_DOCS_SELECTOR_URL: /${{ github.event.repository.name }}/preview/pr-${{ github.event.pull_request.number }}/
          RISE_DOCS_SWITCH_URL: /${{ github.event.repository.name }}/preview/pr-${{ github.event.pull_request.number }}/operator/
          RISE_DOCS_SWITCH_LABEL: Operator docs
        run: npm run build

      - name: Build operator docs preview
        working-directory: docs/engineering
        env:
          ASTRO_BASE: /${{ github.event.repository.name }}/preview/pr-${{ github.event.pull_request.number }}/operator
          RISE_DOCS_SELECTOR_URL: /${{ github.event.repository.name }}/preview/pr-${{ github.event.pull_request.number }}/
          RISE_DOCS_SWITCH_URL: /${{ github.event.repository.name }}/preview/pr-${{ github.event.pull_request.number }}/user/
          RISE_DOCS_SWITCH_LABEL: User docs
        run: npm run build

      - name: Seed Pages site with current live state
        uses: ./.github/actions/seed-pages-site
        with:
          output-dir: docs-preview
          github-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Stage docs preview
        shell: bash
        run: |
          set -euo pipefail
          preview_path="preview/pr-${{ github.event.pull_request.number }}"
          preview_url="/${{ github.event.repository.name }}/${preview_path}"
          mkdir -p "docs-preview/${preview_path}/user" "docs-preview/${preview_path}/operator"
          cp -a docs/user/dist/. "docs-preview/${preview_path}/user/"
          cp -a docs/engineering/dist/. "docs-preview/${preview_path}/operator/"
          cat > "docs-preview/${preview_path}/index.html" <<HTML
          <!doctype html>
          <html lang="en">
            <head>
              <meta charset="utf-8">
              <meta name="viewport" content="width=device-width, initial-scale=1">
              <title>Rise Docs Preview</title>
              <style>
                :root {
                  color-scheme: light dark;
                  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
                  background: #111827;
                  color: #f9fafb;
                }
                body {
                  margin: 0;
                  min-height: 100vh;
                  display: grid;
                  place-items: center;
                }
                main {
                  width: min(100% - 32px, 760px);
                }
                h1 {
                  margin: 0 0 12px;
                  font-size: 36px;
                  line-height: 1.1;
                }
                p {
                  margin: 0 0 28px;
                  color: #cbd5e1;
                  font-size: 17px;
                }
                .choices {
                  display: grid;
                  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
                  gap: 16px;
                }
                a {
                  display: block;
                  padding: 20px;
                  border: 1px solid #334155;
                  border-radius: 8px;
                  background: #1f2937;
                  color: inherit;
                  text-decoration: none;
                }
                a:hover {
                  border-color: #38bdf8;
                }
                strong {
                  display: block;
                  margin-bottom: 8px;
                  font-size: 20px;
                }
                span {
                  color: #cbd5e1;
                }
              </style>
            </head>
            <body>
              <main>
                <h1>Rise Docs Preview</h1>
                <p>Choose which documentation set to read for this pull request.</p>
                <div class="choices">
                  <a href="${preview_url}/user/">
                    <strong>Rise User Docs</strong>
                    <span>Application deployment, CLI usage, environments, service accounts, and project extensions.</span>
                  </a>
                  <a href="${preview_url}/operator/">
                    <strong>Rise Operator Docs</strong>
                    <span>Platform operation, Kubernetes setup, configuration, registries, and production guidance.</span>
                  </a>
                </div>
              </main>
            </body>
          </html>
          HTML

      - name: Configure GitHub Pages
        uses: actions/configure-pages@v5

      - name: Upload docs preview artifact
        uses: actions/upload-pages-artifact@v4
        with:
          path: docs-preview

      - name: Deploy docs preview to GitHub Pages
        id: pages
        uses: actions/deploy-pages@v4

      - name: Resolve preview URL
        id: preview
        shell: bash
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          pr_number="${{ github.event.pull_request.number }}"
          preview_path="/preview/pr-${pr_number}/"
          pages_url="${{ steps.pages.outputs.page_url }}"
          if [[ -z "${pages_url}" ]]; then
            pages_url="$(gh api "repos/${GITHUB_REPOSITORY}/pages" --jq '.html_url' 2>/dev/null || true)"
          fi
          if [[ -z "${pages_url}" ]]; then
            repo_name="${GITHUB_REPOSITORY#*/}"
            pages_url="https://${{ github.repository_owner }}.github.io/${repo_name}/"
          fi
          preview_url="${pages_url%/}${preview_path}"

          echo "path=${preview_path}" >> "$GITHUB_OUTPUT"
          echo "url=${preview_url}" >> "$GITHUB_OUTPUT"
          echo "Docs preview deployed to ${preview_url}" >> "$GITHUB_STEP_SUMMARY"

      - name: Comment on PR
        uses: actions/github-script@v8
        with:
          script: |
            const marker = '<!-- rise-docs-preview -->';
            const previewUrl = '${{ steps.preview.outputs.url }}';
            const previewPath = '${{ steps.preview.outputs.path }}';
            const body = [
              marker,
              `Docs preview: [\`${previewPath}\`](${previewUrl})`,
              '',
              `- [Rise User Docs](${previewUrl}user/)`,
              `- [Rise Operator Docs](${previewUrl}operator/)`,
              '',
              `Updated for commit \`${context.sha.slice(0, 7)}\`.`,
            ].join('\n');

            const issue_number = context.payload.pull_request.number;
            const { owner, repo } = context.repo;
            try {
              const comments = await github.paginate(github.rest.issues.listComments, {
                owner,
                repo,
                issue_number,
                per_page: 100,
              });
              const existing = comments.find((comment) => comment.body?.includes(marker));

              if (existing) {
                await github.rest.issues.updateComment({
                  owner,
                  repo,
                  comment_id: existing.id,
                  body,
                });
              } else {
                await github.rest.issues.createComment({
                  owner,
                  repo,
                  issue_number,
                  body,
                });
              }
            } catch (error) {
              core.warning(`Unable to create or update docs preview comment: ${error.message}`);
            }

  publish-crates:
    name: Publish to crates.io
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/v')
    needs:
      - rust-quality
      - unit-tests-linux
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Set up Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: ${{ env.RUST_VERSION }}

      - name: Verify version matches tag
        run: |
          TAG_VERSION=${GITHUB_REF#refs/tags/v}
          CARGO_VERSION=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[0].version')
          echo "Tag version: $TAG_VERSION"
          echo "Cargo version: $CARGO_VERSION"
          if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
            echo "::error::Tag version ($TAG_VERSION) doesn't match Cargo version ($CARGO_VERSION)"
            exit 1
          fi

      - name: Authenticate with crates.io
        uses: rust-lang/crates-io-auth-action@v1
        id: auth

      - name: Strip path-only workspace deps from Cargo.toml
        # `rise-resource-api`/`rise-resource-store` are path-only workspace
        # crates and aren't published. Cargo refuses to publish a manifest
        # with path-only deps, so strip them and their `dep:` references
        # before invoking `cargo publish`. Other backend deps come from
        # crates.io and stay in the manifest (unreachable but harmless).
        run: |
          sed -i \
            -e '/# --- BEGIN BACKEND/,/# --- END BACKEND/d' \
            -e '/# strip-on-publish$/d' \
            Cargo.toml

      - name: Publish to crates.io
        run: cargo publish --features cli --allow-dirty
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}

  e2e-build:
    name: E2E build tests
    runs-on: ubuntu-latest
    needs: prepare
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Set up Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          toolchain: ${{ env.RUST_VERSION }}

      - name: Rust cache
        uses: Swatinem/rust-cache@v2

      - name: Setup mise
        uses: jdx/mise-action@v4
        with:
          cache: true
          experimental: true

      - name: Install mise tools
        run: mise use -g pack railpack buildkit

      - name: Build Rise CLI
        run: cargo build --release --package rise-deploy --features cli

      - name: Run e2e build tests (no proxy)
        run: RISE_BIN=./target/release/rise bash tests/e2e-build/run.sh --no-proxy

      - name: Run e2e build tests (with proxy)
        run: RISE_BIN=./target/release/rise bash tests/e2e-build/run.sh --only-proxy

  e2e-minikube:
    name: Minikube end-to-end smoke tests
    runs-on: ubuntu-latest
    if: (github.event_name == 'push' && github.ref == 'refs/heads/develop') || (github.event_name == 'pull_request' && needs.prepare.outputs.is_trusted_pr == 'true')
    needs:
      - prepare
      - package-image
      - package-helm
    permissions:
      contents: read
      packages: read
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Setup mise
        uses: jdx/mise-action@v4
        with:
          cache: true
          experimental: true

      - name: Install mise tools
        run: mise use -g minikube kubectl helm

      - name: Start Minikube and run smoke tests
        env:
          RISE_IMAGE_REPOSITORY: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          RISE_IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
        run: bash scripts/ci/e2e-minikube.sh

  e2e-minikube-jfrog-vault:
    name: Minikube JFrog/Vault deploy smoke tests
    runs-on: ubuntu-latest
    if: (github.event_name == 'push' && github.ref == 'refs/heads/develop') || (github.event_name == 'pull_request' && needs.prepare.outputs.is_trusted_pr == 'true')
    needs:
      - prepare
      - package-image
      - package-helm
    permissions:
      contents: read
      packages: read
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Setup mise
        uses: jdx/mise-action@v4
        with:
          cache: true
          experimental: true

      - name: Install mise tools
        run: mise use -g minikube kubectl helm

      - name: Start Minikube and run JFrog/Vault deploy tests
        env:
          RISE_IMAGE_REPOSITORY: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          RISE_CLI_IMAGE_REPOSITORY: ${{ env.REGISTRY }}/${{ env.BUILDER_IMAGE_NAME }}
          RISE_IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
          RISE_E2E_REGISTRY_MODE: jfrog-vault
        run: bash scripts/ci/e2e-minikube.sh

      - name: Upload JFrog/Vault service logs
        if: always()
        uses: actions/upload-artifact@v5
        with:
          name: jfrog-vault-service-logs-${{ github.run_id }}-${{ github.run_attempt }}
          path: .rise-e2e-artifacts/jfrog-vault
          if-no-files-found: ignore
