name: Auto Release

on:
  push:
    branches:
      - main

concurrency:
  group: auto-release-${{ github.ref }}
  cancel-in-progress: false

jobs:
  release-please:
    if: ${{ github.repository == 'Gitlawb/openclaude' }}
    name: Release Please
    runs-on: ubuntu-latest
    permissions:
      contents: write # Create release commits/tags.
      pull-requests: write # Keep the bot release PR draft before Release Please updates it.
    outputs:
      release_created: ${{ steps.release.outputs.release_created }}
      tag_name: ${{ steps.release.outputs.tag_name }}
      version: ${{ steps.release.outputs.version }}
      release_pr: ${{ steps.release.outputs.pr }}
    steps:
      # Release Please's draft option applies only on PR creation. Draft any
      # existing pending bot PR before it updates the branch so an unvalidated
      # head is not left ready for merge.
      - name: Keep an existing Release Please PR draft before updating it
        env:
          GH_REPO: ${{ github.repository }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          RELEASE_BASE_BRANCH: ${{ github.ref_name }}
          RELEASE_REPO_OWNER: ${{ github.repository_owner }}
        run: |
          set -euo pipefail

          expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--"
          matches="$(gh api --paginate --method GET "repos/${GH_REPO}/pulls" \
            -f state=open \
            -f base="$RELEASE_BASE_BRANCH" \
            -f per_page=100 \
            | jq -s -c \
              --arg prefix "$expected_prefix" \
              --arg owner "$RELEASE_REPO_OWNER" \
              --arg base "$RELEASE_BASE_BRANCH" '
                [
                  add[]
                  | select(
                      .state == "open"
                      and (.user.login == "app/github-actions" or .user.login == "github-actions[bot]")
                      and .base.ref == $base
                      and (.head.ref | startswith($prefix))
                      and (.head.sha | length) > 0
                      and .head.repo.owner.login == $owner
                      and .head.repo.full_name == .base.repo.full_name
                    )
                  | {
                      number,
                      state: (.state | ascii_upcase),
                      isDraft: .draft,
                      author: { login: .user.login },
                      baseRefName: .base.ref,
                      headRefName: .head.ref,
                      headRefOid: .head.sha,
                      headRepositoryOwner: { login: .head.repo.owner.login },
                      isCrossRepository: false
                    }
                ]
              ')"
          match_count="$(jq length <<<"$matches")"
          if [ "$match_count" -eq 0 ]; then
            exit 0
          fi
          if [ "$match_count" -ne 1 ]; then
            echo "expected at most one pending Release Please bot PR for $RELEASE_BASE_BRANCH, found $match_count" >&2
            jq . <<<"$matches" >&2
            exit 1
          fi

          number="$(jq -r '.[0].number' <<<"$matches")"
          if [ "$(jq -r '.[0].isDraft' <<<"$matches")" = "false" ]; then
            gh pr ready "$number" --undo
          fi
          if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then
            echo "failed to keep pending Release Please PR #$number in draft state" >&2
            exit 1
          fi

      - name: Run release-please
        id: release
        uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

  # This must be separate from release-please: after a tag/release exists, a
  # transient PR API failure must not suppress npm or Docker publishing.
  prepare-web-release:
    name: Prepare Release Please PR for web sync
    needs: release-please
    if: ${{ github.repository == 'Gitlawb/openclaude' && needs.release-please.result == 'success' }}
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write # Re-draft the bot release PR after Release Please updates it.
    steps:
      # release-please can leave an existing PR non-draft after updating it.
      - name: Keep Release Please PR draft after updates
        env:
          GH_REPO: ${{ github.repository }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          RELEASE_BASE_BRANCH: ${{ github.ref_name }}
          RELEASE_REPO_OWNER: ${{ github.repository_owner }}
          RELEASE_PR_JSON: ${{ needs.release-please.outputs.release_pr }}
        run: |
          set -euo pipefail

          expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--"
          release_pr=""
          if [ -n "${RELEASE_PR_JSON:-}" ] && [ "$RELEASE_PR_JSON" != "null" ]; then
            number="$(jq -r '.number // empty' <<<"$RELEASE_PR_JSON")"
            if [ -n "$number" ]; then
              candidate="$(gh pr view "$number" --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)"
              if [ "$(jq -r .state <<<"$candidate")" = "OPEN" ] \
                && { [ "$(jq -r .author.login <<<"$candidate")" = "app/github-actions" ] \
                  || [ "$(jq -r .author.login <<<"$candidate")" = "github-actions[bot]" ]; } \
                && [ "$(jq -r .baseRefName <<<"$candidate")" = "$RELEASE_BASE_BRANCH" ] \
                && [[ "$(jq -r .headRefName <<<"$candidate")" == "$expected_prefix"* ]] \
                && [ "$(jq -r .headRepositoryOwner.login <<<"$candidate")" = "$RELEASE_REPO_OWNER" ] \
                && [ "$(jq -r .isCrossRepository <<<"$candidate")" = "false" ]; then
                release_pr="$candidate"
              fi
            fi
          fi

          if [ -z "$release_pr" ]; then
            matches="$(gh api --paginate --method GET "repos/${GH_REPO}/pulls" \
              -f state=open \
              -f base="$RELEASE_BASE_BRANCH" \
              -f per_page=100 \
              | jq -s -c \
                --arg prefix "$expected_prefix" \
                --arg owner "$RELEASE_REPO_OWNER" \
                --arg base "$RELEASE_BASE_BRANCH" '
                  [
                    add[]
                    | select(
                        .state == "open"
                        and (.user.login == "app/github-actions" or .user.login == "github-actions[bot]")
                        and .base.ref == $base
                        and (.head.ref | startswith($prefix))
                        and (.head.sha | length) > 0
                        and .head.repo.owner.login == $owner
                        and .head.repo.full_name == .base.repo.full_name
                      )
                    | {
                        number,
                        state: (.state | ascii_upcase),
                        isDraft: .draft,
                        author: { login: .user.login },
                        baseRefName: .base.ref,
                        headRefName: .head.ref,
                        headRefOid: .head.sha,
                        headRepositoryOwner: { login: .head.repo.owner.login },
                        isCrossRepository: false
                      }
                  ]
                ')"
            if [ "$(jq length <<<"$matches")" -eq 0 ]; then
              exit 0
            fi
            if [ "$(jq length <<<"$matches")" -ne 1 ]; then
              echo "expected at most one pending Release Please bot PR after release-please" >&2
              jq . <<<"$matches" >&2
              exit 1
            fi
            release_pr="$(jq -c '.[0]' <<<"$matches")"
          fi

          number="$(jq -r .number <<<"$release_pr")"
          if [ "$(jq -r .isDraft <<<"$release_pr")" = "false" ]; then
            gh pr ready "$number" --undo
          fi
          if [ "$(gh pr view "$number" --json isDraft --jq .isDraft)" != "true" ]; then
            echo "failed to keep pending Release Please PR #$number draft after release-please" >&2
            exit 1
          fi

  # Validate the pending Release Please web entry with read-only credentials.
  # A separate write job applies only the validated releases.ts artifact so
  # PR-head scripts never run with contents/pull-requests write access.
  # Sync failure must not block npm/docker after a release/tag is created.
  validate-web-release:
    name: Validate web release entry
    needs: [release-please, prepare-web-release]
    if: ${{ github.repository == 'Gitlawb/openclaude' && needs.release-please.result == 'success' && needs.prepare-web-release.result == 'success' }}
    runs-on: ubuntu-latest
    concurrency:
      group: sync-web-release-${{ github.ref }}
      cancel-in-progress: false
    permissions:
      contents: read # Check out trusted main and overlay pending release inputs only.
      pull-requests: read # Resolve the pending bot release PR identity.
    outputs:
      found: ${{ steps.release-pr.outputs.found }}
      number: ${{ steps.release-pr.outputs.number }}
      branch: ${{ steps.release-pr.outputs.branch }}
      head_sha: ${{ steps.release-pr.outputs.head_sha }}
    steps:
      - name: Resolve pending Release Please PR
        id: release-pr
        env:
          GH_REPO: ${{ github.repository }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          RELEASE_BASE_BRANCH: ${{ github.ref_name }}
          RELEASE_PR_JSON: ${{ needs.release-please.outputs.release_pr }}
          RELEASE_REPO_OWNER: ${{ github.repository_owner }}
        run: |
          set -euo pipefail

          expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--"

          matches_bot_identity() {
            local release_pr="$1"
            local state author base branch head_sha head_owner cross_repo
            state="$(jq -r .state <<<"$release_pr")"
            author="$(jq -r .author.login <<<"$release_pr")"
            base="$(jq -r .baseRefName <<<"$release_pr")"
            branch="$(jq -r .headRefName <<<"$release_pr")"
            head_sha="$(jq -r .headRefOid <<<"$release_pr")"
            head_owner="$(jq -r .headRepositoryOwner.login <<<"$release_pr")"
            cross_repo="$(jq -r .isCrossRepository <<<"$release_pr")"
            [ "$state" = "OPEN" ] \
              && { [ "$author" = "app/github-actions" ] || [ "$author" = "github-actions[bot]" ]; } \
              && [ "$base" = "$RELEASE_BASE_BRANCH" ] \
              && [[ "$branch" == "$expected_prefix"* ]] \
              && [ -n "$head_sha" ] \
              && [ "$head_owner" = "$RELEASE_REPO_OWNER" ] \
              && [ "$cross_repo" = "false" ]
          }

          discover_bot_prs() {
            # Discover by bot branch prefix, not only the label, so a stripped
            # label cannot leave a ready bot PR unvalidated. Ignore mislabeled
            # non-bot PRs instead of failing the release pipeline.
            gh api --paginate --method GET "repos/${GH_REPO}/pulls" \
              -f state=open \
              -f base="$RELEASE_BASE_BRANCH" \
              -f per_page=100 \
              | jq -s -c \
                --arg prefix "$expected_prefix" \
                --arg owner "$RELEASE_REPO_OWNER" \
                --arg base "$RELEASE_BASE_BRANCH" '
                  [
                    add[]
                    | select(
                        .state == "open"
                        and (.user.login == "app/github-actions" or .user.login == "github-actions[bot]")
                        and .base.ref == $base
                        and (.head.ref | startswith($prefix))
                        and (.head.sha | length) > 0
                        and .head.repo.owner.login == $owner
                        and .head.repo.full_name == .base.repo.full_name
                      )
                    | {
                        number,
                        state: (.state | ascii_upcase),
                        isDraft: .draft,
                        author: { login: .user.login },
                        baseRefName: .base.ref,
                        headRefName: .head.ref,
                        headRefOid: .head.sha,
                        headRepositoryOwner: { login: .head.repo.owner.login },
                        isCrossRepository: false
                      }
                  ]
                '
          }

          release_pr=""
          if [ -n "${RELEASE_PR_JSON:-}" ] && [ "$RELEASE_PR_JSON" != "null" ]; then
            number="$(jq -r '.number // empty' <<<"$RELEASE_PR_JSON")"
            if [ -n "$number" ]; then
              candidate="$(gh pr view "$number" --json number,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepositoryOwner,isCrossRepository)"
              if matches_bot_identity "$candidate"; then
                release_pr="$candidate"
              else
                echo "release-please pr output #$number is not an open same-repo bot release PR; falling back to discovery" >&2
                jq . <<<"$candidate" >&2
              fi
            fi
          fi

          if [ -z "$release_pr" ]; then
            matches="$(discover_bot_prs)"
            match_count="$(jq length <<<"$matches")"
            if [ "$match_count" -eq 0 ]; then
              # If something carries the pending label but is not a bot release
              # PR, ignore it and stay green only when no bot release PR exists.
              labeled="$(gh pr list --state open --base "$RELEASE_BASE_BRANCH" --label 'autorelease: pending' --limit 10 --json number,headRefName,author)"
              if [ "$(jq length <<<"$labeled")" -gt 0 ]; then
                echo "ignoring non-bot autorelease: pending PR(s); no matching Release Please bot PR found" >&2
                jq . <<<"$labeled" >&2
              fi
              echo "found=false" >> "$GITHUB_OUTPUT"
              exit 0
            fi
            if [ "$match_count" -ne 1 ]; then
              echo "expected exactly one pending Release Please bot PR for $RELEASE_BASE_BRANCH, found $match_count" >&2
              jq . <<<"$matches" >&2
              exit 1
            fi
            release_pr="$(jq -c '.[0]' <<<"$matches")"
          fi

          number="$(jq -r .number <<<"$release_pr")"
          branch="$(jq -r .headRefName <<<"$release_pr")"
          head_sha="$(jq -r .headRefOid <<<"$release_pr")"

          echo "found=true" >> "$GITHUB_OUTPUT"
          echo "number=$number" >> "$GITHUB_OUTPUT"
          echo "branch=$branch" >> "$GITHUB_OUTPUT"
          echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"

      - name: Check out trusted main tip
        if: ${{ steps.release-pr.outputs.found == 'true' }}
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ github.sha }}
          fetch-depth: 0
          persist-credentials: false

      - name: Set up Bun for release PR sync
        if: ${{ steps.release-pr.outputs.found == 'true' }}
        uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
        with:
          bun-version-file: .bun-version
          no-cache: true

      - name: Sync and validate web release entry
        id: sync-web
        if: ${{ steps.release-pr.outputs.found == 'true' }}
        env:
          RELEASE_BASE_BRANCH: ${{ github.ref_name }}
          RELEASE_EVENT_SHA: ${{ github.sha }}
          RELEASE_PR_BRANCH: ${{ steps.release-pr.outputs.branch }}
          RELEASE_PR_HEAD_SHA: ${{ steps.release-pr.outputs.head_sha }}
          RELEASE_PR_NUMBER: ${{ steps.release-pr.outputs.number }}
        run: |
          set -euo pipefail

          # Run trusted main scripts against the pending bot PR's release inputs.
          # Do not execute package.json/scripts from the bot PR head.
          git fetch --no-tags origin "$RELEASE_BASE_BRANCH" "$RELEASE_PR_HEAD_SHA"
          release_base_sha="$(git rev-parse "origin/$RELEASE_BASE_BRANCH")"
          if [ "$(git rev-parse HEAD)" != "$RELEASE_EVENT_SHA" ] \
            || [ "$release_base_sha" != "$RELEASE_EVENT_SHA" ]; then
            echo "main advanced during release synchronization; a newer workflow run must validate the new base" >&2
            echo "event=$RELEASE_EVENT_SHA current=$release_base_sha" >&2
            exit 1
          fi
          if [ "$(git merge-base "$release_base_sha" "$RELEASE_PR_HEAD_SHA")" != "$release_base_sha" ]; then
            echo "Release Please PR is not based on the validated main commit" >&2
            exit 1
          fi

          unexpected_release_files="$(git diff --name-only "$release_base_sha...$RELEASE_PR_HEAD_SHA" \
            | grep -Fvx \
              -e .release-please-manifest.json \
              -e CHANGELOG.md \
              -e package.json \
              -e web/src/data/releases.ts \
              || true)"
          if [ -n "$unexpected_release_files" ]; then
            echo "Release Please PR changed files outside its release-input contract" >&2
            printf '%s\n' "$unexpected_release_files" >&2
            exit 1
          fi
          for required_release_file in .release-please-manifest.json CHANGELOG.md package.json; do
            if git diff --quiet "$release_base_sha...$RELEASE_PR_HEAD_SHA" -- "$required_release_file"; then
              echo "Release Please PR is missing required release input $required_release_file" >&2
              exit 1
            fi
          done
          for release_file in .release-please-manifest.json CHANGELOG.md package.json web/src/data/releases.ts; do
            tree_entry="$(git ls-tree "$RELEASE_PR_HEAD_SHA" -- "$release_file")"
            if [[ "$tree_entry" != "100644 blob "*$'\t'"$release_file" ]]; then
              echo "Release Please file must remain a regular non-executable blob: $release_file" >&2
              exit 1
            fi
          done

          mkdir -p .release-sync-input
          git show "${RELEASE_PR_HEAD_SHA}:package.json" > .release-sync-input/package.json
          git show "${RELEASE_PR_HEAD_SHA}:CHANGELOG.md" > CHANGELOG.md
          git show "${RELEASE_PR_HEAD_SHA}:.release-please-manifest.json" > .release-please-manifest.json
          git show "${RELEASE_PR_HEAD_SHA}:web/src/data/releases.ts" > .release-sync-input/releases.ts

          if ! diff -u \
            <(jq -S 'del(.version)' package.json) \
            <(jq -S 'del(.version)' .release-sync-input/package.json); then
            echo "Release Please PR changes package.json fields other than version" >&2
            exit 1
          fi
          if ! diff -u \
            <(jq -S 'del(.["."])' <(git show HEAD:.release-please-manifest.json)) \
            <(jq -S 'del(.["."])' .release-please-manifest.json); then
            echo "Release Please PR changes manifest fields other than the root version" >&2
            exit 1
          fi

          package_version="$(jq -r .version .release-sync-input/package.json)"
          manifest_version="$(jq -r '.["."]' .release-please-manifest.json)"
          base_package_version="$(jq -r .version package.json)"
          base_manifest_version="$(git show HEAD:.release-please-manifest.json | jq -r '.["."]')"
          if [ "$package_version" != "$manifest_version" ] \
            || [ "$base_package_version" != "$base_manifest_version" ]; then
            echo "package and Release Please manifest versions do not match" >&2
            exit 1
          fi
          bun install --frozen-lockfile
          BASE_VERSION="$base_package_version" NEXT_VERSION="$package_version" bun -e '
            import { assertVersionAdvances } from "./scripts/sync-web-release-entry"
            assertVersionAdvances(process.env.BASE_VERSION!, process.env.NEXT_VERSION!)
          '
          if ! git diff --quiet "$release_base_sha...$RELEASE_PR_HEAD_SHA" -- web/src/data/releases.ts; then
            if ! bun -e '
              import { hasGeneratedTopEntry } from "./scripts/sync-web-release-entry"
              const releases = await Bun.file(".release-sync-input/releases.ts").text()
              if (!hasGeneratedTopEntry(releases)) process.exit(1)
            '; then
              echo "Release Please PR has an unmarked releases.ts edit; refusing to overwrite it" >&2
              exit 1
            fi
          fi
          bun run sync:web-release -- --base-ref HEAD
          # Restore overlaid inputs so the cleanliness gate only sees releases.ts.
          git checkout -- CHANGELOG.md .release-please-manifest.json
          bun test ./scripts/sync-web-release-entry.test.ts

          if ! git diff --quiet -- web/src/data/releases.ts; then
            git config user.name "github-actions[bot]"
            git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
            git add web/src/data/releases.ts
            git commit -m "chore(web): sync releases.ts from release-please changelog"
          fi

          # GITHUB_TOKEN pushes do not start ordinary PR checks, so run the
          # repo's standard gates against the synchronized commit before ready.
          bun run typecheck
          bun run typecheck:type-tests
          bun run security:pr-scan -- --base "$release_base_sha" --head HEAD
          git diff --check "$release_base_sha"...HEAD
          bun install --cwd web --frozen-lockfile
          bun run web:typecheck
          bun run web:build

          mkdir -p release-sync-artifact
          cp web/src/data/releases.ts release-sync-artifact/releases.ts
          releases_sha256="$(sha256sum web/src/data/releases.ts | awk '{print $1}')"
          jq -n \
            --arg number "$RELEASE_PR_NUMBER" \
            --arg branch "$RELEASE_PR_BRANCH" \
            --arg base_sha "$release_base_sha" \
            --arg head_sha "$RELEASE_PR_HEAD_SHA" \
            --arg releases_sha256 "$releases_sha256" \
            '{ number: $number, branch: $branch, base_sha: $base_sha, head_sha: $head_sha, releases_sha256: $releases_sha256 }' \
            > release-sync-artifact/metadata.json

          if ! git diff --quiet || ! git diff --cached --quiet; then
            echo "sync validation left uncommitted tracked changes" >&2
            git status --short
            exit 1
          fi

      - name: Upload validated releases.ts artifact
        if: ${{ steps.release-pr.outputs.found == 'true' }}
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          # Keep the artifact addressable when only the failed push job is
          # retried: run_attempt changes on a retry, while this validated head
          # remains the push job's immutable input.
          name: web-release-sync-${{ github.run_id }}-${{ steps.release-pr.outputs.head_sha }}
          path: release-sync-artifact/
          if-no-files-found: error
          # A full validation rerun for the same head replaces its prior artifact.
          overwrite: true
          retention-days: 1

  push-web-release:
    name: Push web release entry
    needs: validate-web-release
    if: ${{ github.repository == 'Gitlawb/openclaude' && needs.validate-web-release.outputs.found == 'true' }}
    runs-on: ubuntu-latest
    concurrency:
      group: sync-web-release-${{ github.ref }}
      cancel-in-progress: false
    permissions:
      contents: write # Push only the validated releases.ts commit to the bot branch.
      pull-requests: write # Keep the bot PR draft until push lands, then mark ready.
    steps:
      - name: Check out Release Please PR head
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ needs.validate-web-release.outputs.head_sha }}
          fetch-depth: 0
          persist-credentials: false

      - name: Download validated releases.ts artifact
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
        with:
          name: web-release-sync-${{ github.run_id }}-${{ needs.validate-web-release.outputs.head_sha }}
          path: release-sync-artifact

      - name: Apply validated releases.ts and push
        id: push-sync
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_REPO: ${{ github.repository }}
          RELEASE_PR_BRANCH: ${{ needs.validate-web-release.outputs.branch }}
          RELEASE_PR_NUMBER: ${{ needs.validate-web-release.outputs.number }}
          RELEASE_PR_HEAD_SHA: ${{ needs.validate-web-release.outputs.head_sha }}
          RELEASE_BASE_BRANCH: ${{ github.ref_name }}
        run: |
          set -euo pipefail

          metadata_path=release-sync-artifact/metadata.json
          if ! jq -e '
            type == "object"
            and (.number | type == "string" and length > 0)
            and (.branch | type == "string" and length > 0)
            and (.base_sha | type == "string" and test("^[0-9a-f]{40}$"))
            and (.head_sha | type == "string" and test("^[0-9a-f]{40}$"))
            and (.releases_sha256 | type == "string" and test("^[0-9a-f]{64}$"))
          ' "$metadata_path" >/dev/null; then
            echo "validated artifact metadata is malformed" >&2
            exit 1
          fi
          number="$(jq -r .number "$metadata_path")"
          branch="$(jq -r .branch "$metadata_path")"
          base_sha="$(jq -r .base_sha "$metadata_path")"
          head_sha="$(jq -r .head_sha "$metadata_path")"
          releases_sha256="$(jq -r .releases_sha256 "$metadata_path")"
          if [ "${number}" != "$RELEASE_PR_NUMBER" ] \
            || [ "${branch}" != "$RELEASE_PR_BRANCH" ] \
            || [ "${head_sha}" != "$RELEASE_PR_HEAD_SHA" ]; then
            echo "validated artifact metadata does not match the resolved release PR" >&2
            jq . "$metadata_path" >&2
            exit 1
          fi

          expected_sha256="$releases_sha256"
          actual_sha256="$(sha256sum release-sync-artifact/releases.ts | awk '{print $1}')"
          if [ "$actual_sha256" != "$expected_sha256" ]; then
            echo "validated releases.ts digest mismatch" >&2
            exit 1
          fi

          git fetch --no-tags origin "$RELEASE_BASE_BRANCH"
          current_base_sha="$(git rev-parse "origin/$RELEASE_BASE_BRANCH")"
          if [ "$current_base_sha" != "$base_sha" ]; then
            echo "main advanced after web-release validation; refusing to push a stale artifact" >&2
            echo "validated=$base_sha current=$current_base_sha" >&2
            exit 1
          fi

          current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid,author,headRepositoryOwner,isCrossRepository)"
          expected_prefix="release-please--branches--${RELEASE_BASE_BRANCH}--components--"
          if [ "$(jq -r .state <<<"$current")" != "OPEN" ] \
            || { [ "$(jq -r .author.login <<<"$current")" != "app/github-actions" ] \
              && [ "$(jq -r .author.login <<<"$current")" != "github-actions[bot]" ]; } \
            || [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \
            || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \
            || [[ "$(jq -r .headRefName <<<"$current")" != "$expected_prefix"* ]] \
            || [ "$(jq -r .headRepositoryOwner.login <<<"$current")" != "${GITHUB_REPOSITORY_OWNER}" ] \
            || [ "$(jq -r .isCrossRepository <<<"$current")" != "false" ]; then
            echo "release PR changed or closed during sync" >&2
            jq . <<<"$current" >&2
            exit 1
          fi

          if [ "$(jq -r .isDraft <<<"$current")" != "true" ]; then
            gh pr ready "$RELEASE_PR_NUMBER" --undo
            if [ "$(gh pr view "$RELEASE_PR_NUMBER" --json isDraft --jq .isDraft)" != "true" ]; then
              echo "failed to keep release PR #$RELEASE_PR_NUMBER draft before push" >&2
              exit 1
            fi
          fi

          current_sha="$(jq -r .headRefOid <<<"$current")"
          validated_release_blob="$(git hash-object release-sync-artifact/releases.ts)"
          already_synchronized=false
          if [ "$current_sha" != "$RELEASE_PR_HEAD_SHA" ]; then
            git fetch --no-tags origin "$current_sha"
            if ! git diff --quiet "$RELEASE_PR_HEAD_SHA" "$current_sha" -- . ':(exclude)web/src/data/releases.ts'; then
              echo "release PR head changed outside the validated release artifact" >&2
              jq . <<<"$current" >&2
              exit 1
            fi
            remote_release_entry="$(git ls-tree "$current_sha" -- web/src/data/releases.ts)"
            expected_release_entry="100644 blob ${validated_release_blob}"$'\t''web/src/data/releases.ts'
            if [ "$remote_release_entry" != "$expected_release_entry" ]; then
              echo "release PR head changed to an unvalidated release entry or file mode" >&2
              jq . <<<"$current" >&2
              exit 1
            fi
            already_synchronized=true
          fi

          if [ "$already_synchronized" = "true" ]; then
            # The captured validation head is stale after a concurrent push.
            # Adopt the exact verified remote commit before checking cleanliness.
            git switch --detach "$current_sha"
            echo "release PR already has the validated releases.ts artifact"
          else
            # Apply only the validated file — do not execute PR-head scripts here.
            cp release-sync-artifact/releases.ts web/src/data/releases.ts
            applied_sha256="$(sha256sum web/src/data/releases.ts | awk '{print $1}')"
            if [ "$applied_sha256" != "$expected_sha256" ]; then
              echo "failed to apply validated releases.ts" >&2
              exit 1
            fi
            if ! git diff --quiet -- web/src/data/releases.ts; then
              git config user.name "github-actions[bot]"
              git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
              git add web/src/data/releases.ts
              git commit -m "chore(web): sync releases.ts from release-please changelog"
            else
              echo "release PR already has the validated releases.ts artifact"
            fi
          fi

          if ! git diff --quiet || ! git diff --cached --quiet; then
            echo "push preparation left uncommitted tracked changes" >&2
            git status --short
            exit 1
          fi

          local_sha="$(git rev-parse HEAD)"
          if [ "$already_synchronized" = "true" ]; then
            local_sha="$current_sha"
          elif [ "$current_sha" = "$RELEASE_PR_HEAD_SHA" ]; then
            auth_header="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 -w0)"
            if ! GIT_CONFIG_COUNT=1 \
              GIT_CONFIG_KEY_0=http.https://github.com/.extraheader \
              GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth_header}" \
              git push \
              --force-with-lease="refs/heads/${RELEASE_PR_BRANCH}:${RELEASE_PR_HEAD_SHA}" \
              "https://github.com/${GITHUB_REPOSITORY}.git" \
              "HEAD:${RELEASE_PR_BRANCH}"; then
              # A concurrent sync may have already pushed the same commit.
              remote_sha="$(gh pr view "$RELEASE_PR_NUMBER" --json headRefOid --jq .headRefOid)"
              if [ "$remote_sha" = "$local_sha" ]; then
                echo "release PR already points at the synchronized commit after lease race"
              else
                echo "release PR head moved during push to an unexpected commit" >&2
                echo "remote=$remote_sha local=$local_sha" >&2
                exit 1
              fi
            fi
          elif [ "$current_sha" = "$local_sha" ]; then
            echo "release PR already points at the synchronized commit"
          else
            echo "release PR head changed to an unvalidated commit" >&2
            jq . <<<"$current" >&2
            exit 1
          fi

          echo "head_sha=$local_sha" >> "$GITHUB_OUTPUT"
          echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT"

      - name: Mark synchronized release PR ready for review
        if: ${{ steps.push-sync.outcome == 'success' }}
        env:
          GH_REPO: ${{ github.repository }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          RELEASE_PR_NUMBER: ${{ needs.validate-web-release.outputs.number }}
          RELEASE_PR_BRANCH: ${{ needs.validate-web-release.outputs.branch }}
          RELEASE_BASE_BRANCH: ${{ github.ref_name }}
          VALIDATED_BASE_SHA: ${{ steps.push-sync.outputs.base_sha }}
          VALIDATED_HEAD_SHA: ${{ steps.push-sync.outputs.head_sha }}
        run: |
          set -euo pipefail

          current_base_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BASE_BRANCH}" --jq .object.sha)"
          current="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)"
          if [ "$current_base_sha" != "$VALIDATED_BASE_SHA" ] \
            || [ "$(jq -r .state <<<"$current")" != "OPEN" ] \
            || [ "$(jq -r .baseRefName <<<"$current")" != "$RELEASE_BASE_BRANCH" ] \
            || [ "$(jq -r .headRefName <<<"$current")" != "$RELEASE_PR_BRANCH" ] \
            || [ "$(jq -r .headRefOid <<<"$current")" != "$VALIDATED_HEAD_SHA" ]; then
            echo "release PR no longer points at the synchronized commit" >&2
            jq . <<<"$current" >&2
            if [ "$(jq -r .state <<<"$current")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$current")" = "false" ]; then
              gh pr ready "$RELEASE_PR_NUMBER" --undo
            fi
            exit 1
          fi

          if [ "$(jq -r .isDraft <<<"$current")" = "true" ]; then
            gh pr ready "$RELEASE_PR_NUMBER"
          fi

          final_base_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/${RELEASE_BASE_BRANCH}" --jq .object.sha)"
          final="$(gh pr view "$RELEASE_PR_NUMBER" --json state,isDraft,baseRefName,headRefName,headRefOid)"
          if [ "$final_base_sha" != "$VALIDATED_BASE_SHA" ] \
            || [ "$(jq -r .state <<<"$final")" != "OPEN" ] \
            || [ "$(jq -r .isDraft <<<"$final")" != "false" ] \
            || [ "$(jq -r .baseRefName <<<"$final")" != "$RELEASE_BASE_BRANCH" ] \
            || [ "$(jq -r .headRefName <<<"$final")" != "$RELEASE_PR_BRANCH" ] \
            || [ "$(jq -r .headRefOid <<<"$final")" != "$VALIDATED_HEAD_SHA" ]; then
            echo "release PR changed while it was being marked ready" >&2
            jq . <<<"$final" >&2
            if [ "$(jq -r .state <<<"$final")" = "OPEN" ] && [ "$(jq -r .isDraft <<<"$final")" = "false" ]; then
              gh pr ready "$RELEASE_PR_NUMBER" --undo
            fi
            exit 1
          fi

  # Zero-warning install gate: pack the release tag and install it globally in
  # a sandbox (cold + upgrade scenarios) on the supported Node floor (22 →
  # npm 10) and current (24 → npm 11), whose warning output differs. Publish
  # is blocked unless the real `npm install -g` experience is clean.
  install-verify:
    name: Verify clean npm install (Node ${{ matrix.node-version }})
    needs: release-please
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24]
    steps:
      - name: Checkout release tag
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ needs.release-please.outputs.tag_name }}
          persist-credentials: false

      - name: Set up Node.js
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: ${{ matrix.node-version }}

      - name: Set up Bun
        uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
        with:
          bun-version-file: .bun-version

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Build
        run: bun run build

      - name: Verify zero-warning global install
        run: bun run install:verify

  publish-npm:
    name: Publish to npm
    needs: [release-please, install-verify]
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    environment: release
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Checkout release tag
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ needs.release-please.outputs.tag_name }}
          fetch-depth: 0

      - name: Set up Node.js
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org

      - name: Set up Bun
        uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
        with:
          bun-version-file: .bun-version

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Build
        run: bun run build

      - name: Run unit tests
        run: bun test --feature=UNATTENDED_RETRY --max-concurrency=1

      - name: Smoke test
        run: bun run smoke

      # Final install-hygiene gate on the publishing machine itself (the
      # install-verify job covered Node 22/24 already); packs with
      # --ignore-scripts to reuse the dist/ built above. Supersedes the old
      # `npm pack --dry-run` (contents are asserted inside the verify).
      - name: Verify zero-warning global install
        run: bun run install:verify

      - name: Clear token auth for trusted publishing
        run: |
          unset NODE_AUTH_TOKEN
          echo "NODE_AUTH_TOKEN=" >> "$GITHUB_ENV"

      - name: Publish to npm
        run: npm publish --access public --provenance

      - name: Verify npm latest dist-tag
        env:
          EXPECTED_VERSION: ${{ needs.release-please.outputs.version }}
        run: |
          set -euo pipefail

          if [ -z "${EXPECTED_VERSION}" ]; then
            echo "release-please did not provide an expected version" >&2
            exit 1
          fi

          for attempt in $(seq 1 30); do
            published_version="$(npm view @gitlawb/openclaude version 2>/dev/null || true)"
            latest_tag="$(npm view @gitlawb/openclaude dist-tags.latest 2>/dev/null || true)"
            latest_version="$(npm view @gitlawb/openclaude@latest version 2>/dev/null || true)"

            echo "Attempt ${attempt}: version=${published_version:-<empty>} latest=${latest_tag:-<empty>} @latest=${latest_version:-<empty>} expected=${EXPECTED_VERSION}"

            if [ "$published_version" = "$EXPECTED_VERSION" ] && \
               [ "$latest_tag" = "$EXPECTED_VERSION" ] && \
               [ "$latest_version" = "$EXPECTED_VERSION" ]; then
              echo "npm latest verified for @gitlawb/openclaude@${EXPECTED_VERSION}"
              exit 0
            fi

            sleep 10
          done

          echo "npm latest dist-tag did not resolve to ${EXPECTED_VERSION}" >&2
          echo "Observed package version: ${published_version:-<empty>}" >&2
          echo "Observed dist-tags.latest: ${latest_tag:-<empty>}" >&2
          echo "Observed @latest version: ${latest_version:-<empty>}" >&2
          exit 1

      - name: Release summary
        run: |
          {
            echo "## Released ${{ needs.release-please.outputs.tag_name }}"
            echo
            echo "- npm: https://www.npmjs.com/package/@gitlawb/openclaude"
            echo "- GitHub: https://github.com/Gitlawb/openclaude/releases/tag/${{ needs.release-please.outputs.tag_name }}"
          } >> "$GITHUB_STEP_SUMMARY"

  docker:
    name: Build & Push Docker Image
    needs: release-please
    if: ${{ needs.release-please.outputs.release_created == 'true' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout release tag
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: ${{ needs.release-please.outputs.tag_name }}

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
            type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
            type=raw,value=latest

      - name: Build and load locally
        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
        with:
          context: .
          load: true
          tags: openclaude:smoke
          cache-from: type=gha

      - name: Smoke test
        run: docker run --rm openclaude:smoke --version

      - name: Build and push
        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
