---
name: Release Production
on:
  workflow_dispatch:
    inputs:
      release_source:
        description: |
          Source ref for the production build.
          - staging_tag: build the latest (or explicit) staging tag — recommended; the artifact
            QA already exercised gets promoted to production.
          - main_head: build main @ HEAD with a fresh version bump (escape hatch for hotfixes).
        required: true
        type: choice
        default: staging_tag
        options: [staging_tag, main_head]
      staging_tag:
        description:
          Specific staging tag to promote (e.g. v1.2.4-staging). Leave empty to use the
          latest matching tag. Ignored when release_source = main_head.
        required: false
        type: string
        default: ""
      release_type:
        description:
          Version increment type. Only consulted when release_source = main_head;
          staging_tag promotions inherit the staging tag's version verbatim.
        required: false
        default: patch
        type: choice
        options: [patch, minor, major]
      skip_e2e:
        description:
          Skip the entire pretest phase (unit/rust plus E2E) and continue
          directly to create-release + build matrix. Use only when the
          required pretest signal is already known (e.g. promoting a
          staging tag whose pretests already ran green) and you need to
          unblock a production cut.
        required: false
        type: boolean
        default: false
permissions:
  contents: write
  packages: write
concurrency:
  # Distinct group from release-staging.yml so promotions and staging cuts can
  # run independently. The two workflows never touch the same tags or refs.
  group: release-production
  cancel-in-progress: false
# ---------------------------------------------------------------------------
# Job dependency graph
#
#   prepare-build
#        │
#        ├─── pretest-tests   (reusable test-reusable.yml — unit + rust)
#        ├─── pretest-e2e     (reusable e2e-reusable.yml — all 3 OS, full)
#        │
#        ├─── create-release
#        │         │
#        │    ┌────┴───────────────┬────────────────┐
#        │    │                    │                │
#        │  build-desktop    build-cli-linux    build-docker
#        │  (reusable wf)    (Linux tarballs)   (GHCR image)
#        │    │                    │                │
#        │    └────────┬───────────┴────────────────┘
#        │             │
#        │      publish-updater-manifest
#        │             │
#        │      publish-release
#        │             │
#        │      record-sentry-deploy
#        │
#        └─── cleanup-failed-release (on failure)
#
# The actual desktop build / sign / Sentry / artifact-upload pipeline lives in
# `.github/workflows/build-desktop.yml` and is shared with release-staging.yml.
# ---------------------------------------------------------------------------
jobs:
  # =========================================================================
  # Phase 1: Resolve build ref and (for main_head) bump version + create tag
  # =========================================================================
  prepare-build:
    name: Prepare build context
    runs-on: ubuntu-latest
    environment: Production
    outputs:
      version: ${{ steps.resolve.outputs.version }}
      tag: ${{ steps.resolve.outputs.tag }}
      sha: ${{ steps.resolve.outputs.sha }}
      # First 12 chars of `sha` — matches the truncation done at runtime by
      # app/src/utils/config.ts, app/vite.config.ts, src/main.rs, and
      # app/src-tauri/src/lib.rs when they compute the canonical
      # `openhuman@<version>+<short_sha>` release tag. Use this (not the
      # full `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
        # Both flows operate against main: main_head bumps and tags from main
        # HEAD; staging_tag promotion creates the production tag from main
        # (the tag's commit object lives in the same repo, so the tag is
        # reachable via direct ref regardless of where it sits in history).
        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

      # ── Path A: main_head ────────────────────────────────────────────────
      # Bump version on main, commit, push, tag.
      - name: Compute next version and sync release files (main_head)
        if: inputs.release_source == 'main_head'
        id: bump
        run: node scripts/release/bump-version.js "${{ inputs.release_type }}"
      - name: Verify release version sync (main_head)
        if: inputs.release_source == 'main_head'
        run: node scripts/release/verify-version-sync.js "${{ steps.bump.outputs.version }}"
      - name: Refresh Cargo.lock files (main_head)
        if: inputs.release_source == 'main_head'
        # 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: Ensure tag does not already exist (main_head)
        if: inputs.release_source == 'main_head'
        env:
          TAG: ${{ steps.bump.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 (main_head)
        if: inputs.release_source == 'main_head'
        id: push
        env:
          VERSION: ${{ steps.bump.outputs.version }}
          TAG: ${{ steps.bump.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(release): v${VERSION}"
          git push origin main
          git tag -a "$TAG" -m "Release $TAG"
          git push origin "$TAG"
          echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

      # ── Path B: staging_tag ──────────────────────────────────────────────
      # Resolve a previously cut staging tag, derive the production version
      # from its package.json (no further bump — patch was applied during the
      # staging cut), and create the production v<version> tag at the same
      # commit. The artifact contents are byte-identical to what staging
      # validated, modulo build-time env (BASE_URL, OPENHUMAN_APP_ENV, etc.).
      - name: Resolve staging tag (staging_tag)
        if: inputs.release_source == 'staging_tag'
        id: stagingtag
        env:
          EXPLICIT_TAG: ${{ inputs.staging_tag }}
        run: |
          set -euo pipefail
          if [ -n "$EXPLICIT_TAG" ]; then
            STAGING_TAG="$EXPLICIT_TAG"
          else
            STAGING_TAG="$(git tag -l 'v*-staging' --sort=-v:refname | head -n 1)"
          fi
          if [ -z "$STAGING_TAG" ]; then
            echo "No staging tags found matching v*-staging."
            exit 1
          fi
          # Reject anything that isn't a `vX.Y.Z-staging` tag we cut
          # ourselves — keeps an operator from accidentally promoting a
          # hand-pushed ref or a stray pre-release tag.
          if ! [[ "$STAGING_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-staging$ ]]; then
            echo "Invalid staging tag format: $STAGING_TAG (expected vX.Y.Z-staging)"
            exit 1
          fi
          if ! git rev-parse --verify "refs/tags/$STAGING_TAG" >/dev/null 2>&1; then
            echo "Staging tag not present locally: $STAGING_TAG"
            exit 1
          fi
          STAGING_SHA="$(git rev-list -n 1 "$STAGING_TAG")"
          # Strip the -staging suffix to get the production version.
          PROD_VERSION="${STAGING_TAG#v}"
          PROD_VERSION="${PROD_VERSION%-staging}"
          PROD_TAG="v${PROD_VERSION}"
          # Sanity-check every authoritative version source on the staging
          # commit. They must all agree with PROD_VERSION — if they drift,
          # the bundled installer reports a different version than the
          # GitHub Release tag, and the Sentry release tag stops matching
          # what the running binary emits.
          read_json_version() {
            git show "${STAGING_TAG}:$1" | node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>process.stdout.write(JSON.parse(d).version||""))'
          }
          read_cargo_version() {
            git show "${STAGING_TAG}:$1" | sed -n '/^\[package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p; }' | head -n 1
          }
          for src in \
            "app/package.json" \
            "app/src-tauri/tauri.conf.json" \
            "app/src-tauri/Cargo.toml" \
            "Cargo.toml"; do
            case "$src" in
              *.json) actual="$(read_json_version "$src")" ;;
              *.toml) actual="$(read_cargo_version "$src")" ;;
            esac
            if [ "$actual" != "$PROD_VERSION" ]; then
              echo "Staging tag $STAGING_TAG version mismatch: $src reports '$actual' but tag implies '$PROD_VERSION'"
              exit 1
            fi
          done
          echo "staging_tag=$STAGING_TAG" >> "$GITHUB_OUTPUT"
          echo "staging_sha=$STAGING_SHA" >> "$GITHUB_OUTPUT"
          echo "prod_version=$PROD_VERSION" >> "$GITHUB_OUTPUT"
          echo "prod_tag=$PROD_TAG" >> "$GITHUB_OUTPUT"
      - name: Ensure production tag does not already exist (staging_tag)
        if: inputs.release_source == 'staging_tag'
        env:
          TAG: ${{ steps.stagingtag.outputs.prod_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: Create production tag at staging commit (staging_tag)
        if: inputs.release_source == 'staging_tag'
        id: promote
        env:
          STAGING_TAG: ${{ steps.stagingtag.outputs.staging_tag }}
          STAGING_SHA: ${{ steps.stagingtag.outputs.staging_sha }}
          PROD_TAG: ${{ steps.stagingtag.outputs.prod_tag }}
        run: |
          git tag -a "$PROD_TAG" "$STAGING_SHA" -m "Release $PROD_TAG (promoted from $STAGING_TAG)"
          git push origin "$PROD_TAG"
          echo "sha=$STAGING_SHA" >> "$GITHUB_OUTPUT"

      - name: Resolve build outputs
        id: resolve
        shell: bash
        env:
          RELEASE_SOURCE: ${{ inputs.release_source }}
          BUMP_VERSION: ${{ steps.bump.outputs.version }}
          BUMP_TAG: ${{ steps.bump.outputs.tag }}
          PUSH_SHA: ${{ steps.push.outputs.sha }}
          PROMOTE_VERSION: ${{ steps.stagingtag.outputs.prod_version }}
          PROMOTE_TAG: ${{ steps.stagingtag.outputs.prod_tag }}
          PROMOTE_SHA: ${{ steps.promote.outputs.sha }}
        run: |
          if [ "$RELEASE_SOURCE" = "main_head" ]; then
            VERSION="$BUMP_VERSION"
            TAG="$BUMP_TAG"
            SHA="$PUSH_SHA"
          else
            VERSION="$PROMOTE_VERSION"
            TAG="$PROMOTE_TAG"
            SHA="$PROMOTE_SHA"
          fi
          BUILD_REF="$TAG"
          BASE_URL="https://api.tinyhumans.ai/"
          # 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"
          echo "build_ref=$BUILD_REF" >> "$GITHUB_OUTPUT"
          echo "base_url=$BASE_URL" >> "$GITHUB_OUTPUT"

  # =========================================================================
  # Phase 1b: Pretest gate — run the full test + E2E suite across every
  # target OS exactly once on the build ref before we spin up the release
  # draft or any signed-build matrix. Pretest failures abort the workflow
  # before `create-release` runs, so a busted commit never produces a
  # half-finished GH Release that has to be cleaned up.
  # =========================================================================
  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: Create draft GitHub release
  # =========================================================================
  create-release:
    name: Create GitHub release
    runs-on: ubuntu-latest
    environment: Production
    needs: [prepare-build, pretest-tests, pretest-e2e]
    if: >-
      always()
      && needs.prepare-build.result == 'success'
      && (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'))
    outputs:
      release_id: ${{ steps.create.outputs.release_id }}
      upload_url: ${{ steps.create.outputs.upload_url }}
    steps:
      - name: Create draft release with generated notes
        id: create
        uses: actions/github-script@v8
        with:
          script: |
            const tag = '${{ needs.prepare-build.outputs.tag }}';
            const version = '${{ needs.prepare-build.outputs.version }}';
            const target = '${{ needs.prepare-build.outputs.sha }}';
            const { owner, repo } = context.repo;
            try {
              await github.rest.repos.getReleaseByTag({ owner, repo, tag });
              core.setFailed(`Release already exists for ${tag}`);
              return;
            } catch (error) {
              if (error.status !== 404) {
                throw error;
              }
            }
            const release = await github.rest.repos.createRelease({
              owner,
              repo,
              tag_name: tag,
              target_commitish: target,
              name: `OpenHuman v${version}`,
              draft: true,
              prerelease: false,
              generate_release_notes: true,
            });
            core.setOutput('release_id', String(release.data.id));
            core.setOutput('upload_url', release.data.upload_url);

  # =========================================================================
  # Phase 3a: Build desktop artifacts (delegated to reusable workflow)
  # =========================================================================
  build-desktop:
    name: Build desktop matrix
    needs: [prepare-build, create-release]
    # `always()` is load-bearing: when `skip_e2e=true` the pretest jobs are
    # `skipped`, and GitHub propagates that skipped status transitively to any
    # downstream job lacking an explicit status function — even though we only
    # `needs` create-release here, the build would otherwise be skipped along
    # with the pretests. Same pattern below for build-cli-linux / build-docker /
    # publish-updater-manifest / publish-release / tag-docker-latest /
    # record-sentry-deploy. See release-staging.yml for the working precedent.
    if: always() && needs.create-release.result == 'success'
    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: production
      build_profile: release
      telegram_bot_username: openhumanaibot
      # with_macos_signing defaults to true — left implicit; production
      # always notarizes. See build-desktop.yml inputs.
      with_release_upload: true
      release_id: ${{ needs.create-release.outputs.release_id }}
      build_sidecar: false
      skip_pretests: ${{ inputs.skip_e2e }}

  # =========================================================================
  # Phase 3b: Build & push Docker image (runs parallel with build-desktop).
  #
  # Publishes `ghcr.io/tinyhumansai/openhuman-core` with two immutable tags
  # per release:
  #   - :v<version>            — matches the GitHub Release tag (e.g. v1.2.4)
  #   - :<version>             — bare SemVer for tooling that strips the v
  #
  # `:latest` is intentionally NOT pushed here. If a downstream phase
  # (build-cli-linux, publish-updater-manifest, the asset-validation gate
  # in publish-release) fails, the immutable tags are deleted by
  # cleanup-failed-release while the release is rolled back. Pushing
  # :latest in this job would move the moving tag onto an image whose
  # release got cleaned up, leaving downstream `docker pull …:latest`
  # consumers on a build that has no GitHub Release behind it. The
  # `tag-docker-latest` job below promotes :latest only after
  # `publish-release` succeeds.
  #
  # linux/amd64 only for now. arm64 users pull the standalone CLI tarball
  # (`build-cli-linux` matrix) or build the image from source. Adding arm64
  # via QEMU here triples build time on Rust-heavy stages; revisit when an
  # `ubuntu-24.04-arm` runner is wired into a per-arch matrix + manifest job.
  # =========================================================================
  build-docker:
    name: "Docker: build and push"
    needs: [prepare-build, create-release]
    if: always() && needs.create-release.result == 'success'
    runs-on: ubuntu-latest
    environment: Production
    env:
      REGISTRY: ghcr.io
      IMAGE_NAME: tinyhumansai/openhuman-core
    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: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Compute image tags
        id: image-tags
        env:
          REGISTRY: ${{ env.REGISTRY }}
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
          TAG: ${{ needs.prepare-build.outputs.tag }}
          VERSION: ${{ needs.prepare-build.outputs.version }}
        run: |
          set -euo pipefail
          base="${REGISTRY}/${IMAGE_NAME}"
          {
            echo "tags<<EOF"
            echo "${base}:${TAG}"
            echo "${base}:${VERSION}"
            echo "EOF"
          } >> "$GITHUB_OUTPUT"
      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          file: Dockerfile
          push: true
          platforms: linux/amd64
          tags: ${{ steps.image-tags.outputs.tags }}
          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-production
          cache-to: type=gha,scope=release-production,mode=max
      - name: Verify pushed image is pullable
        run: |
          set -euo pipefail
          image="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare-build.outputs.tag }}"
          docker pull "$image"
          docker image inspect "$image" >/dev/null

  # =========================================================================
  # Phase 3c: Build standalone Linux openhuman-core tarballs and attach them
  # to the GitHub Release. Operators on Linux servers without Docker pull a
  # plain tarball + sha256 from the release page; cloud-deploy.md links here.
  #
  # arm64 uses GitHub-hosted ubuntu-24.04-arm to avoid QEMU emulation
  # (matches release-packages.yml). If that runner is unavailable for the
  # repo's plan, fall back to ubuntu-22.04 + cross-rs (see comment in
  # release-packages.yml `build-cli-linux-arm64`).
  # =========================================================================
  build-cli-linux:
    name: "CLI: ${{ matrix.target }}"
    needs: [prepare-build, create-release]
    if: always() && needs.create-release.result == 'success'
    environment: Production
    runs-on: ${{ matrix.runner }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - runner: ubuntu-22.04
            target: x86_64-unknown-linux-gnu
          - runner: ubuntu-24.04-arm
            target: aarch64-unknown-linux-gnu
    steps:
      - name: Checkout build ref
        uses: actions/checkout@v5
        with:
          ref: ${{ needs.prepare-build.outputs.build_ref }}
          fetch-depth: 1
          submodules: recursive
      - name: Install Rust (rust-toolchain.toml)
        uses: dtolnay/rust-toolchain@1.93.0
      - name: Cache Cargo
        uses: Swatinem/rust-cache@v2
        with:
          key: ${{ matrix.target }}-release
      - name: Install system dependencies
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y --no-install-recommends \
            pkg-config libssl-dev build-essential cmake \
            libasound2-dev libxdo-dev libxtst-dev libx11-dev libevdev-dev \
            clang
      - name: Verify Sentry DSN is present
        env:
          OPENHUMAN_CORE_SENTRY_DSN: ${{ vars.OPENHUMAN_CORE_SENTRY_DSN || vars.OPENHUMAN_SENTRY_DSN }}
        run: |
          if [ -z "${OPENHUMAN_CORE_SENTRY_DSN}" ]; then
            echo "::error::vars.OPENHUMAN_CORE_SENTRY_DSN (or legacy vars.OPENHUMAN_SENTRY_DSN) is empty — the Linux CLI tarball would ship without crash reporting."
            exit 1
          fi
          echo "OPENHUMAN_CORE_SENTRY_DSN is set (length=${#OPENHUMAN_CORE_SENTRY_DSN})"
      - name: Build openhuman-core binary
        env:
          OPENHUMAN_CORE_SENTRY_DSN: ${{ vars.OPENHUMAN_CORE_SENTRY_DSN || vars.OPENHUMAN_SENTRY_DSN }}
          # Match the runtime release tag (`openhuman@<version>+<short_sha>`)
          # baked elsewhere — see prepare-build.outputs.short_sha comment.
          OPENHUMAN_BUILD_SHA: ${{ needs.prepare-build.outputs.short_sha }}
          OPENHUMAN_APP_ENV: production
        run: cargo build --release --bin openhuman-core
      - name: Package and upload tarball to release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          UPLOAD_REPO: ${{ github.repository }}
          VERSION: ${{ needs.prepare-build.outputs.version }}
          TARGET: ${{ matrix.target }}
        run: |
          bash scripts/release/package-cli-tarball.sh \
            target/release/openhuman-core \
            "$VERSION" \
            "$TARGET"

  # =========================================================================
  # Phase 3d: Generate and upload latest.json for the Tauri auto-updater.
  # Runs after every platform has uploaded its updater artifact (.sig files
  # from createUpdaterArtifacts) so the manifest can reference all four
  # platform entries.
  # =========================================================================
  publish-updater-manifest:
    name: Publish updater manifest (latest.json)
    needs: [prepare-build, create-release, build-desktop]
    if: always() && needs.build-desktop.result == 'success'
    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: Generate and upload latest.json
        shell: bash
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAG: ${{ needs.prepare-build.outputs.tag }}
          VERSION: ${{ needs.prepare-build.outputs.version }}
          REPO: tinyhumansai/openhuman
        run: bash scripts/release/publish-updater-manifest.sh

  # =========================================================================
  # Phase 4: Publish the draft release (waits for ALL build phases)
  # =========================================================================
  publish-release:
    name: Publish draft release
    runs-on: ubuntu-latest
    environment: Production
    needs:
      - prepare-build
      - create-release
      - build-desktop
      - build-cli-linux
      - build-docker
      - publish-updater-manifest
    if: >-
      always()
      && needs.build-desktop.result == 'success'
      && needs.build-cli-linux.result == 'success'
      && needs.build-docker.result == 'success'
      && needs.publish-updater-manifest.result == 'success'
    steps:
      - name: Checkout validation scripts
        uses: actions/checkout@v5
        with:
          ref: ${{ needs.prepare-build.outputs.build_ref }}
          fetch-depth: 1

      - name: Validate release assets match latest.json
        shell: bash
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAG: ${{ needs.prepare-build.outputs.tag }}
          RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
        run: |
          set -euo pipefail
          # Fetch the release by id, not by tag: at this point the release is
          # still a draft, and `GET /releases/tags/{tag}` returns 404 for
          # drafts. The id-keyed endpoint works for drafts and published
          # releases alike.
          gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" > /tmp/openhuman-release.json
          gh release download "$TAG" \
            --repo "$GITHUB_REPOSITORY" \
            --pattern latest.json \
            --dir /tmp \
            --clobber
          scripts/validate-release-assets.sh /tmp/openhuman-release.json /tmp/latest.json

      - name: Validate required installer assets exist
        uses: actions/github-script@v8
        with:
          script: |
            const releaseId = Number('${{ needs.create-release.outputs.release_id }}');
            const { owner, repo } = context.repo;
            const { data: assets } = await github.rest.repos.listReleaseAssets({
              owner,
              repo,
              release_id: releaseId,
              per_page: 100,
            });
            const names = assets.map((a) => a.name);
            const requiredPatterns = [
              /OpenHuman_.*_aarch64\.dmg$/,
              /OpenHuman_.*_x64\.dmg$/,
              /(OpenHuman_.*_x64-setup\.exe$|OpenHuman_.*_x64.*\.msi$)/,
              // Linux desktop installer consumed by scripts/install.sh and
              // advertised in latest.json as linux-x86_64.
              /OpenHuman_.*_amd64\.AppImage$/,
              // Auto-updater manifest — without this, installed clients can't
              // discover new releases via plugins.updater.endpoints.
              /^latest\.json$/,
              // Linux standalone openhuman-core CLI tarballs (build-cli-linux).
              // Operators on headless Linux servers pull these instead of the
              // Tauri bundle; cloud-deploy.md documents both arches.
              /^openhuman-core-.*-x86_64-unknown-linux-gnu\.tar\.gz$/,
              /^openhuman-core-.*-x86_64-unknown-linux-gnu\.tar\.gz\.sha256$/,
              /^openhuman-core-.*-aarch64-unknown-linux-gnu\.tar\.gz$/,
              /^openhuman-core-.*-aarch64-unknown-linux-gnu\.tar\.gz\.sha256$/,
            ];
            const missing = requiredPatterns.filter((pattern) => !names.some((name) => pattern.test(name)));
            if (missing.length > 0) {
              core.setFailed(`Missing required installer assets. Got: ${names.join(', ')}`);
              return;
            }
            core.info('All required installer assets are present.');

      - name: Publish release
        uses: actions/github-script@v8
        with:
          script: |
            const releaseId = Number('${{ needs.create-release.outputs.release_id }}');
            await github.rest.repos.updateRelease({
              owner: context.repo.owner,
              repo: context.repo.repo,
              release_id: releaseId,
              draft: false,
            });
            core.info(`Published release ${releaseId}`);

  # =========================================================================
  # Phase 4b: Promote :latest to the just-published image.
  #
  # `build-docker` only pushed the immutable :v<version> / :<version> tags.
  # We delay :latest until publish-release has actually flipped the GitHub
  # Release out of draft, so a downstream failure (build-cli-linux,
  # publish-updater-manifest, the asset-validation gate) cleans up the
  # tagged image without leaving :latest pointing at a build that has no
  # release behind it. Uses `docker buildx imagetools create` to add the
  # extra tag without re-pulling the build context — it operates against
  # the registry manifest, not local layers.
  # =========================================================================
  tag-docker-latest:
    name: "Docker: tag :latest"
    runs-on: ubuntu-latest
    environment: Production
    needs: [prepare-build, publish-release]
    if: always() && needs.publish-release.result == 'success'
    env:
      REGISTRY: ghcr.io
      IMAGE_NAME: tinyhumansai/openhuman-core
    steps:
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Promote :latest
        env:
          REGISTRY: ${{ env.REGISTRY }}
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
          TAG: ${{ needs.prepare-build.outputs.tag }}
        run: |
          set -euo pipefail
          src="${REGISTRY}/${IMAGE_NAME}:${TAG}"
          dst="${REGISTRY}/${IMAGE_NAME}:latest"
          docker buildx imagetools create --tag "$dst" "$src"

  # =========================================================================
  # Phase 5: Record a single Sentry deploy marker once the release has
  # actually been published. Hangs off `publish-release` so a failed build
  # (which gets cleaned up by `cleanup-failed-release`) doesn't write a
  # deploy row. `sentry-cli releases deploys ... new` does NOT deduplicate
  # by (release, env), so this stays single-runner.
  # =========================================================================
  record-sentry-deploy:
    name: Record Sentry deploy marker
    runs-on: ubuntu-latest
    environment: Production
    needs: [prepare-build, publish-release]
    if: always() && needs.publish-release.result == 'success'
    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: production
        run: |
          set -euo pipefail
          echo "==> Recording deploy marker: ${SENTRY_RELEASE} -> ${SENTRY_ENVIRONMENT}"
          sentry-cli releases deploys "${SENTRY_RELEASE}" new \
            -e "${SENTRY_ENVIRONMENT}"

  # =========================================================================
  # Cleanup: remove draft release + tag if ANY build phase failed
  # =========================================================================
  cleanup-failed-release:
    name: Remove release and tag if build failed
    runs-on: ubuntu-latest
    environment: Production
    needs:
      - prepare-build
      - pretest-tests
      - pretest-e2e
      - create-release
      - build-desktop
      - build-cli-linux
      - build-docker
      - publish-updater-manifest
    # Two cleanup paths:
    #   (a) pretest failed → no release yet, but `prepare-build` already
    #       pushed a tag and (for main_head) a chore(release) commit; we
    #       still need to delete the tag so a rerun doesn't trip the
    #       "tag already exists" guard.
    #   (b) any build phase failed after create-release succeeded — the
    #       original behavior — delete the draft release, the tag, and any
    #       pushed Docker image versions.
    if: >-
      always()
      && needs.prepare-build.result == 'success'
      && (
        (needs.create-release.result != 'success'
         && (needs.pretest-tests.result == 'failure' || needs.pretest-tests.result == 'cancelled'
             || needs.pretest-e2e.result == 'failure' || needs.pretest-e2e.result == 'cancelled'))
        || (needs.create-release.result == 'success'
            && (needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled'
                || needs.build-cli-linux.result == 'failure' || needs.build-cli-linux.result == 'cancelled'
                || needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled'
                || needs.publish-updater-manifest.result == 'failure' || needs.publish-updater-manifest.result == 'cancelled'))
      )
    steps:
      - name: Delete GitHub release
        # Skip on the pretest-failure cleanup path: create-release didn't run,
        # so there's no draft release to delete (only an orphaned tag).
        if: needs.create-release.result == 'success'
        uses: actions/github-script@v8
        with:
          script: |
            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const releaseId = Number('${{ needs.create-release.outputs.release_id }}');
            if (!Number.isFinite(releaseId) || releaseId <= 0) {
              core.setFailed('Invalid or missing release_id; cannot delete release.');
              return;
            }
            try {
              await github.rest.repos.deleteRelease({ owner, repo, release_id: releaseId });
              core.info(`Deleted release ${releaseId}`);
            } catch (e) {
              core.warning(`deleteRelease failed: ${e.message}`);
            }
      - name: Delete remote 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 tag ${tag}`);
            } catch (e) {
              if (e.status === 404) {
                core.info(`Tag ${tag} already absent on remote`);
              } else {
                throw e;
              }
            }
      - name: Delete published Docker image versions
        # If `build-docker` already pushed but a downstream phase failed, the
        # GHCR image would otherwise outlive the GitHub Release. Walk the
        # `:v<version>` and `:<version>` tags we just pushed and remove the
        # underlying package version. `:latest` is left alone — the previous
        # release is still pointed at it, and clobbering the moving tag here
        # would orphan downstream pulls. Skipped on the pretest-failure
        # cleanup path where build-docker never ran.
        if: needs.build-docker.result == 'success'
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAG: ${{ needs.prepare-build.outputs.tag }}
          VERSION: ${{ needs.prepare-build.outputs.version }}
        run: |-
          set -uo pipefail
          PACKAGE="openhuman-core"
          for IMAGE_TAG in "${TAG}" "${VERSION}"; do
            echo "Attempting to delete Docker tag: ${IMAGE_TAG}"
            VERSION_ID="$(gh api \
              -H "Accept: application/vnd.github+json" \
              "/orgs/tinyhumansai/packages/container/${PACKAGE}/versions" \
              --paginate --jq ".[] | select(.metadata.container.tags[]? == \"${IMAGE_TAG}\") | .id" 2>/dev/null | head -1)"
            if [ -n "$VERSION_ID" ]; then
              gh api -X DELETE "/orgs/tinyhumansai/packages/container/${PACKAGE}/versions/${VERSION_ID}" || true
              echo "Deleted image version ${VERSION_ID} (tag ${IMAGE_TAG})"
            else
              echo "Tag ${IMAGE_TAG} not found or already deleted"
            fi
          done
