---
name: Release Staging
on:
  workflow_dispatch:
    inputs:
      skip_e2e:
        description:
          Skip the entire pretest phase (unit/rust plus E2E) and continue
          directly to the desktop/docker staging build. Use only when the
          required pretest signal is already known and you need to unblock a
          staging cut.
        required: false
        type: boolean
        default: false
permissions:
  # `contents: write` is required for the patch bump commit and the
  # `v<version>-staging` tag push performed by `prepare-build` below.
  contents: write
  packages: read
concurrency:
  group: release-staging
  cancel-in-progress: false
# ---------------------------------------------------------------------------
# Job dependency graph
#
#   prepare-build
#        │
#        ├── pretest-tests   (reusable test-reusable.yml — unit + rust;
#        │                    optional when `skip_e2e` is true)
#        ├── pretest-e2e     (reusable e2e-reusable.yml — all 3 OS, full suite;
#        │                    optional when `skip_e2e` is true)
#        │
#        ├── build-desktop      (delegated to .github/workflows/build-desktop.yml)
#        ├── build-docker       (build only — no GHCR push on staging)
#        │
#   record-sentry-deploy
#        │
#   cleanup-failed-staging (on failure)
#
# The pretest jobs are a hard gate — `build-desktop` and `build-docker`
# only start once unit/rust/E2E have all passed across every target. This
# guarantees we never produce a staging tag whose installers were built
# against unproven code.
#
# The actual desktop build / Sentry / artifact-upload pipeline lives in
# `.github/workflows/build-desktop.yml` and is shared with
# release-production.yml.
# ---------------------------------------------------------------------------
jobs:
  # =========================================================================
  # Phase 1: Patch-bump on `main` and create the immutable
  # `v<version>-staging` tag at that commit. The build matrix below then
  # checks out the tag (not main HEAD) so reruns reproduce byte-for-byte.
  # Production promotion (`release-production.yml`,
  # `release_source = staging_tag`) reads this tag verbatim and creates a
  # `v<version>` tag at the same commit.
  # =========================================================================
  prepare-build:
    name: Prepare build context
    runs-on: ubuntu-latest
    # Reuse the Production GitHub Actions environment so Sentry vars
    # (`OPENHUMAN_*_SENTRY_DSN`, `SENTRY_PROJECT_*`, `SENTRY_ORG`,
    # `SENTRY_AUTH_TOKEN`) and `VITE_DEBUG` resolve here too. Staging
    # events differentiate from production via the `environment` tag set
    # at runtime — separate Sentry projects are not needed.
    environment: Production
    outputs:
      version: ${{ steps.resolve.outputs.version }}
      # Immutable staging tag created by this run, e.g. `v1.2.4-staging`.
      # Downstream consumers (release-production.yml `staging_tag` promotion,
      # Sentry, installer asset names) reference this rather than the bare SHA.
      tag: ${{ steps.resolve.outputs.tag }}
      sha: ${{ steps.resolve.outputs.sha }}
      # First 12 chars of `sha`. Matches the truncation runtime code in
      # config.ts / vite.config.ts / main.rs / app/src-tauri/src/lib.rs
      # applies when computing `openhuman@<version>+<short_sha>`. Use this
      # (not `sha`) anywhere CI constructs SENTRY_RELEASE so uploaded
      # artifacts attach to the same release events report.
      short_sha: ${{ steps.resolve.outputs.short_sha }}
      build_ref: ${{ steps.resolve.outputs.build_ref }}
      base_url: ${{ steps.resolve.outputs.base_url }}
    steps:
      - name: Enforce main branch
        if: github.ref != 'refs/heads/main'
        run: |
          echo "This workflow can only run from main. Current ref: $GITHUB_REF"
          exit 1
      - name: Generate GitHub App token
        id: app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ secrets.XGITHUB_APP_ID }}
          private-key: ${{ secrets.XGITHUB_APP_PRIVATE_KEY }}
      - name: Checkout main
        uses: actions/checkout@v5
        with:
          ref: main
          fetch-depth: 0
          token: ${{ steps.app-token.outputs.token }}
          submodules: recursive
      - name: Setup pnpm
        uses: pnpm/action-setup@v5
      - name: Setup Node.js
        uses: actions/setup-node@v5
        with:
          node-version: 24.x
          package-manager-cache: false
      - name: Configure Git
        env:
          APP_TOKEN: ${{ steps.app-token.outputs.token }}
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git remote set-url origin https://${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git
          git fetch origin --tags --prune --prune-tags
          git checkout main
          git pull origin main --ff-only
      # Patch-only bump for staging cuts. Minor/major promotions are owned
      # by `release-production.yml` and only happen on the production path.
      # Bump commit lands on `main` (we don't maintain a separate `staging`
      # branch) and the immutable `v<version>-staging` tag pinpoints the
      # exact main commit QA validated, so production promotion can later
      # find the tagged commit reachable from main.
      - name: Bump patch version
        id: bump
        run: node scripts/release/bump-version.js patch
      - name: Verify version sync
        run: node scripts/release/verify-version-sync.js "${{ steps.bump.outputs.version }}"
      - name: Refresh Cargo.lock files
        # Cargo.toml [package].version is bumped above but the matching
        # entries in both Cargo.lock files are not — without this step the
        # lockfiles stay pinned to the previous version and the next local
        # `cargo` invocation rewrites them, leaving an uncommitted diff.
        run: |
          cargo update --workspace --manifest-path Cargo.toml
          cargo update --workspace --manifest-path app/src-tauri/Cargo.toml
      - name: Compute staging tag
        id: tagname
        env:
          VERSION: ${{ steps.bump.outputs.version }}
        run: |
          STAGING_TAG="v${VERSION}-staging"
          echo "tag=${STAGING_TAG}" >> "$GITHUB_OUTPUT"
      - name: Ensure staging tag does not already exist
        env:
          TAG: ${{ steps.tagname.outputs.tag }}
        run: |
          if git rev-parse "$TAG" >/dev/null 2>&1; then
            echo "Tag already exists locally: $TAG"
            exit 1
          fi
          if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then
            echo "Tag already exists on origin: $TAG"
            exit 1
          fi
      - name: Commit, push and tag staging cut
        id: push
        env:
          VERSION: ${{ steps.bump.outputs.version }}
          TAG: ${{ steps.tagname.outputs.tag }}
        run: |
          git add app/package.json app/src-tauri/tauri.conf.json app/src-tauri/Cargo.toml Cargo.toml app/src-tauri/Cargo.lock Cargo.lock
          git commit -m "chore(staging): v${VERSION}"
          git push origin main
          git tag -a "$TAG" -m "Staging cut $TAG"
          git push origin "$TAG"
          echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
      - name: Resolve build outputs
        id: resolve
        shell: bash
        env:
          VERSION: ${{ steps.bump.outputs.version }}
          TAG: ${{ steps.tagname.outputs.tag }}
          SHA: ${{ steps.push.outputs.sha }}
        run: |
          # Match the 12-char truncation runtime code applies to
          # VITE_BUILD_SHA / OPENHUMAN_BUILD_SHA when constructing the
          # release tag at startup, so SENTRY_RELEASE assembled in CI
          # agrees with the tag events emit.
          SHORT_SHA="${SHA:0:12}"
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
          echo "sha=$SHA" >> "$GITHUB_OUTPUT"
          echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
          # Build from the immutable staging tag rather than main HEAD so
          # reruns of this workflow rebuild the same content even if main
          # has moved on (e.g. another patch cut, or a hotfix landed).
          echo "build_ref=$TAG" >> "$GITHUB_OUTPUT"
          echo "base_url=https://staging-api.tinyhumans.ai/" >> "$GITHUB_OUTPUT"

  # =========================================================================
  # Phase 1b: Pretest gate — run the full test + E2E suite across every
  # target OS exactly once on the staging commit before any build job
  # spins up. A failure here aborts the matrix (and `cleanup-failed-staging`
  # deletes the tag) without burning four signed Tauri builds first.
  # =========================================================================
  pretest-tests:
    name: Pretest — unit + rust
    needs: [prepare-build]
    if: ${{ !inputs.skip_e2e }}
    uses: ./.github/workflows/test-reusable.yml
    with:
      ref: ${{ needs.prepare-build.outputs.build_ref }}
  pretest-e2e:
    name: Pretest — E2E (all OS, full suite)
    needs: [prepare-build]
    if: ${{ !inputs.skip_e2e }}
    uses: ./.github/workflows/e2e-reusable.yml
    with:
      ref: ${{ needs.prepare-build.outputs.build_ref }}
      run_linux: true
      run_macos: true
      run_windows: true
      full: true

  # =========================================================================
  # Phase 2: Build desktop artifacts (delegated to reusable workflow)
  # =========================================================================
  build-desktop:
    name: Build desktop matrix
    needs: [prepare-build, pretest-tests, pretest-e2e]
    if: >-
      always()
      && (needs.pretest-tests.result == 'success'
          || (inputs.skip_e2e && needs.pretest-tests.result == 'skipped'))
      && (needs.pretest-e2e.result == 'success'
          || (inputs.skip_e2e && needs.pretest-e2e.result == 'skipped'))
    uses: ./.github/workflows/build-desktop.yml
    secrets: inherit
    with:
      build_ref: ${{ needs.prepare-build.outputs.build_ref }}
      tag: ${{ needs.prepare-build.outputs.tag }}
      version: ${{ needs.prepare-build.outputs.version }}
      sha: ${{ needs.prepare-build.outputs.sha }}
      short_sha: ${{ needs.prepare-build.outputs.short_sha }}
      base_url: ${{ needs.prepare-build.outputs.base_url }}
      app_env: staging
      build_profile: debug
      telegram_bot_username: alphahumantest_bot
      # Notarize staging too — QA installs the bundle from the Actions
      # artifact, and unnotarized .app launches are blocked by Gatekeeper on
      # macOS ≥ 10.15 (“damaged and can’t be opened”) without out-of-band
      # `xattr -dr com.apple.quarantine` workarounds.
      with_macos_signing: true
      with_release_upload: false
      # No publish-updater-manifest job in staging — producing .sig artifacts
      # would just leave them stranded in the Actions artifact tree.
      with_updater: false
      # Standalone openhuman-core CLI ships from the production cut only:
      # `build-docker` pushes `ghcr.io/tinyhumansai/openhuman-core` and
      # `build-cli-linux` attaches Linux x86_64 / aarch64 tarballs to the
      # GitHub Release (see release-production.yml). Staging does not
      # publish either surface — the matrix-built sidecar artifact had no
      # real consumer. Set `build_sidecar: true` to re-enable a per-platform
      # CLI Actions artifact + its Sentry DIF upload for QA spot-checks.
      build_sidecar: false
      skip_pretests: ${{ inputs.skip_e2e }}

  # =========================================================================
  # Phase 2b: Build the openhuman-core Docker image without pushing.
  # Mirrors the production `build-docker` job so a Dockerfile regression
  # surfaces on the staging cut — no GHCR push, no `:staging-*` tag
  # pollution. `deploy-smoke.yml` already covers the build path on PRs
  # that touch Dockerfile / src; this is the equivalent gate at the
  # staging-tag boundary so a green staging cut means the next prod
  # promotion's GHCR push will succeed too.
  # =========================================================================
  build-docker:
    name: "Docker: build (no push)"
    needs: [prepare-build, pretest-tests, pretest-e2e]
    if: >-
      always()
      && (needs.pretest-tests.result == 'success'
          || (inputs.skip_e2e && needs.pretest-tests.result == 'skipped'))
      && (needs.pretest-e2e.result == 'success'
          || (inputs.skip_e2e && needs.pretest-e2e.result == 'skipped'))
    runs-on: ubuntu-latest
    environment: Production
    steps:
      - name: Checkout build ref
        uses: actions/checkout@v5
        with:
          ref: ${{ needs.prepare-build.outputs.build_ref }}
          fetch-depth: 1
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Build image (no push)
        uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile
          push: false
          load: true
          platforms: linux/amd64
          tags: openhuman-core:staging-${{ needs.prepare-build.outputs.tag }}
          labels: |
            org.opencontainers.image.source=https://github.com/${{ github.repository }}
            org.opencontainers.image.revision=${{ needs.prepare-build.outputs.sha }}
            org.opencontainers.image.version=${{ needs.prepare-build.outputs.version }}
            org.opencontainers.image.title=openhuman-core
          cache-from: type=gha,scope=release-staging
          cache-to: type=gha,scope=release-staging,mode=max

  # =========================================================================
  # Phase 3: Record a single Sentry deploy marker once the matrix is
  # complete. Lives in its own job (not inside the reusable workflow)
  # because `sentry-cli releases deploys ... new` does NOT deduplicate by
  # (release, env) — running it inside the matrix would add one row per
  # platform (×4). One row per release is the right shape: re-runs of CI
  # for the same release intentionally produce additional rows representing
  # separate deploy attempts.
  # =========================================================================
  record-sentry-deploy:
    name: Record Sentry deploy marker
    runs-on: ubuntu-latest
    environment: Production
    needs: [prepare-build, build-desktop, build-docker]
    env:
      SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
      SENTRY_URL: ${{ vars.SENTRY_URL }}
    steps:
      - name: Install sentry-cli
        if: env.SENTRY_AUTH_TOKEN != ''
        shell: bash
        run: curl -sSf https://sentry.io/get-cli/ | bash
      - name: Record deploy marker
        if: env.SENTRY_AUTH_TOKEN != ''
        shell: bash
        env:
          SENTRY_URL: ${{ vars.SENTRY_URL }}
          SENTRY_ORG: ${{ vars.SENTRY_ORG }}
          # Marker lives on the React project's release; events from all
          # surfaces share the same `openhuman@<version>+<short_sha>` release
          # tag, so the marker on any single project's release shows in
          # Sentry's "Deploys" tab for that release group.
          SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_REACT }}
          SENTRY_RELEASE:
            openhuman@${{ needs.prepare-build.outputs.version }}+${{
            needs.prepare-build.outputs.short_sha }}
          SENTRY_ENVIRONMENT: staging
        run: |
          set -euo pipefail
          echo "==> Recording deploy marker: ${SENTRY_RELEASE} -> ${SENTRY_ENVIRONMENT}"
          sentry-cli releases deploys "${SENTRY_RELEASE}" new \
            -e "${SENTRY_ENVIRONMENT}"

  # =========================================================================
  # Cleanup: delete the staging tag if the build matrix failed. The version
  # bump commit on `main` stays — reverting it would risk a race with
  # concurrent merges. The next staging cut just continues from the new
  # patch number; the small “gap” in patch numbers is acceptable.
  # =========================================================================
  cleanup-failed-staging:
    name: Remove staging tag if build failed
    runs-on: ubuntu-latest
    environment: Production
    needs: [prepare-build, pretest-tests, pretest-e2e, build-desktop, build-docker]
    if: >-
      always()
      && needs.prepare-build.result == 'success'
      && (needs.pretest-tests.result == 'failure' || needs.pretest-tests.result == 'cancelled'
          || needs.pretest-e2e.result == 'failure' || needs.pretest-e2e.result == 'cancelled'
          || needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled'
          || needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled')
    steps:
      - name: Delete remote staging tag
        uses: actions/github-script@v8
        with:
          script: |
            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const tag = '${{ needs.prepare-build.outputs.tag }}';
            try {
              await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` });
              core.info(`Deleted remote staging tag ${tag}`);
            } catch (e) {
              if (e.status === 404) {
                core.info(`Staging tag ${tag} already absent on remote`);
              } else {
                throw e;
              }
            }
