name: Build Community Desktop Release
run-name: Community Desktop ${{ (github.event_name == 'workflow_dispatch' || contains(github.ref_name, '-beta.')) && 'beta' || 'release' }} ${{ inputs.version || github.ref_name }}

on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:
    inputs:
      version:
        description: 'Beta version, e.g. 5.3.7-beta.3 (no v prefix); workflow must run from main'
        required: true
        type: string
      source_ref:
        description: 'Reviewed Community branch, tag or commit to build; workflow still runs from main'
        required: true
        type: string
        default: main
      release_epoch:
        description: 'Positive update release sequence for the test package'
        required: true
        type: string
      publish_release:
        description: 'Create and publish a GitHub Beta release after all platforms pass'
        required: true
        type: boolean
        default: false

concurrency:
  group: community-desktop-${{ github.ref }}-${{ inputs.version || github.ref_name }}
  cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }}

permissions:
  contents: read

jobs:
  resolve:
    name: Resolve release metadata
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.metadata.outputs.version }}
      native_version: ${{ steps.metadata.outputs.native_version }}
      update_channel: ${{ steps.metadata.outputs.update_channel }}
      tag_name: ${{ steps.metadata.outputs.tag_name }}
      channel: ${{ steps.metadata.outputs.channel }}
      tag_push: ${{ steps.metadata.outputs.tag_push }}
      create_release: ${{ steps.metadata.outputs.create_release }}
      publish: ${{ steps.metadata.outputs.publish }}
      latest: ${{ steps.metadata.outputs.latest }}
      release_epoch: ${{ steps.update_metadata.outputs.release_epoch }}
      build_sha: ${{ steps.update_metadata.outputs.build_sha }}
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
        with:
          fetch-depth: 0
          persist-credentials: false
      - name: Validate version
        id: metadata
        shell: bash
        env:
          EVENT_NAME: ${{ github.event_name }}
          GITHUB_REF: ${{ github.ref }}
          REF_NAME: ${{ github.ref_name }}
          INPUT_VERSION: ${{ inputs.version }}
          SOURCE_REF: ${{ inputs.source_ref }}
          PUBLISH_RELEASE: ${{ inputs.publish_release }}
        run: |
          set -euo pipefail

          if [ "${EVENT_NAME}" = "push" ]; then
            if [[ "${REF_NAME}" != v* ]]; then
              echo "Tag must start with v: ${REF_NAME}" >&2
              exit 1
            fi
            version="${REF_NAME#v}"
            tag_push=true
          else
            if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
              echo "Beta builds can only run from the protected main branch: ${GITHUB_REF}" >&2
              exit 1
            fi
            if [ -z "${SOURCE_REF//[[:space:]]/}" ]; then
              echo 'An explicit source_ref is required for a Beta build.' >&2
              exit 1
            fi
            version="${INPUT_VERSION}"
            tag_push=false
          fi

          native_version=$(bash script/package/community-version.sh "${version}")
          major="${version%%.*}"
          if [ "${major}" -lt 4 ]; then
            echo "Public Community releases start at major version 4: ${version}" >&2
            exit 1
          fi
          update_channel=STABLE
          if [[ "${version}" == *-beta.* ]]; then update_channel=BETA; fi

          # A Beta tag publishes a release on the Beta channel while the Latest pointer keeps
          # naming the stable release, so only clients that opted into Beta updates receive it.
          # A numeric tag stays the formal release route that updates the stable channel and
          # Docker.
          if [ "${tag_push}" = true ]; then
            create_release=true
            if [ "${update_channel}" = BETA ]; then
              channel=beta
              publish=false
              latest=false
            else
              channel=release
              publish=true
              latest=true
            fi
          else
            if [ "${update_channel}" != BETA ]; then
              echo 'Manual Beta builds require a version ending in -beta.N.' >&2
              exit 1
            fi
            channel=beta
            create_release="${PUBLISH_RELEASE}"
            publish=false
            latest=false
          fi

          {
            echo "version=${version}"
            echo "native_version=${native_version}"
            echo "update_channel=${update_channel}"
            echo "tag_name=v${version}"
            echo "channel=${channel}"
            echo "tag_push=${tag_push}"
            echo "create_release=${create_release}"
            echo "publish=${publish}"
            echo "latest=${latest}"
          } >> "${GITHUB_OUTPUT}"

      - name: Resolve source checkout
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
        with:
          ref: ${{ github.event_name == 'workflow_dispatch' && inputs.source_ref || github.sha }}
          path: source
          persist-credentials: false

      - name: Resolve update metadata
        id: update_metadata
        shell: bash
        env:
          EVENT_NAME: ${{ github.event_name }}
          TAG_NAME: ${{ steps.metadata.outputs.tag_name }}
          INPUT_EPOCH: ${{ inputs.release_epoch }}
        run: |
          set -euo pipefail
          if [ "${EVENT_NAME}" = "push" ]; then
            # actions/checkout fetches the tagged commit into the tag ref, which
            # replaces the annotated tag object in this clone; fetch the tag
            # itself before reading the release annotation.
            git fetch --force origin "refs/tags/${TAG_NAME}:refs/tags/${TAG_NAME}"
            test "$(git cat-file -t "refs/tags/${TAG_NAME}")" = tag
            epoch=$(git for-each-ref "refs/tags/${TAG_NAME}" --format='%(contents)' | sed -n 's/^release_epoch: *//p')
          else
            epoch="${INPUT_EPOCH}"
          fi
          if [[ ! "${epoch}" =~ ^[1-9][0-9]*$ ]]; then
            echo 'A positive release_epoch is required in the annotated release tag or test inputs.' >&2
            exit 1
          fi
          echo "release_epoch=${epoch}" >> "${GITHUB_OUTPUT}"
          build_sha=$(git -C source rev-parse HEAD)
          echo "build_sha=${build_sha}" >> "${GITHUB_OUTPUT}"
          {
            echo "### Community package inputs"
            echo "- Source commit: ${build_sha}"
            echo "- Workflow commit: ${GITHUB_SHA}"
            echo "- Update sequence: ${epoch}"
          } >> "${GITHUB_STEP_SUMMARY}"

  notify_start:
    name: Notify Feishu Community beta build start
    needs: resolve
    if: ${{ needs.resolve.outputs.channel == 'beta' }}
    environment: community-beta-signing
    runs-on: ubuntu-latest
    steps:
      - name: Send Feishu start notification
        env:
          FEISHU_WEBHOOK_URL: ${{ secrets.COMMUNITY_FEISHU_RELEASE_NOTIFY_WEBHOOK }}
          VERSION: ${{ needs.resolve.outputs.version }}
          RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
        shell: python
        run: |
          import json
          import os
          import urllib.error
          import urllib.request

          webhook = os.environ.get("FEISHU_WEBHOOK_URL", "")
          if not webhook:
              print("FEISHU_RELEASE_NOTIFY_WEBHOOK is not configured; skip start notification.")
              raise SystemExit(0)

          lines = [
              "Chat2DB Community beta build started",
              f"Version: {os.environ['VERSION']}",
              "Distribution: GitHub Actions artifacts only",
              f"Workflow run: {os.environ['RUN_URL']}",
          ]
          payload = json.dumps(
              {"msg_type": "text", "content": {"text": "\n".join(lines)}},
              ensure_ascii=False,
          ).encode("utf-8")
          request = urllib.request.Request(
              webhook,
              data=payload,
              headers={"Content-Type": "application/json"},
              method="POST",
          )
          try:
              with urllib.request.urlopen(request, timeout=15) as response:
                  result = json.loads(response.read().decode("utf-8"))
              if result.get("code", 0) != 0:
                  print(f"::warning::Feishu start notification was rejected: {result}")
          except urllib.error.URLError as exc:
              print(f"::warning::Feishu start notification failed: {exc}")

  build:
    name: Build ${{ matrix.artifact_name }}
    needs: resolve
    # Stable and beta releases share the signing environment: the keys, the Windows
    # signing connection and the macOS notarization credentials are the same.
    environment: community-beta-signing
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: macos-15
            target: mac
            artifact_name: macos-arm64
          - os: macos-15-intel
            target: mac
            artifact_name: macos-x64
          - os: windows-latest
            target: win
            artifact_name: windows
          - os: ubuntu-22.04
            target: linux
            artifact_name: linux-x64
          - os: ubuntu-22.04-arm
            target: linux
            artifact_name: linux-arm64

    runs-on: ${{ matrix.os }}
    env:
      COMMUNITY_SOURCE_DIR: ${{ github.workspace }}

    steps:
      - name: Check out resolved source
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
        with:
          ref: ${{ needs.resolve.outputs.build_sha }}
          persist-credentials: false

      - name: Check out workflow packaging helpers
        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
        with:
          ref: ${{ github.sha }}
          path: .community-packaging
          persist-credentials: false
          sparse-checkout-cone-mode: false
          sparse-checkout: |
            /script/package/
            /jpackage/installer.iss
            /jpackage/lang/

      - name: Set up JDK 17
        uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00
        with:
          distribution: temurin
          java-version: '17'
          cache: maven

      - name: Set up Node.js
        uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
        with:
          node-version: '22.22.2'
          cache: yarn
          cache-dependency-path: chat2db-community-client/yarn.lock

      - name: Install Ubuntu packaging dependencies
        if: ${{ runner.os == 'Linux' }}
        run: |
          sudo apt-get update
          sudo apt-get install -y fakeroot rpm desktop-file-utils file curl zip

      - name: Test macOS native signing selection
        if: ${{ runner.os == 'macOS' }}
        shell: bash
        run: bash .community-packaging/script/package/tests/sign-macos-native-libraries-test.sh

      - name: Import macOS code-signing certificate
        if: ${{ runner.os == 'macOS' }}
        shell: bash
        run: |
          set -euo pipefail
          echo "${{ secrets.MAC_CERTS }}" | base64 --decode > certificate.p12
          KEYCHAIN_PATH="${RUNNER_TEMP}/build.keychain"
          security create-keychain -p "${{ secrets.COMMUNITY_MAC_CERTS_PASSWORD }}" "${KEYCHAIN_PATH}"
          security default-keychain -s "${KEYCHAIN_PATH}"
          security list-keychains -d user -s "${KEYCHAIN_PATH}"
          security set-keychain-settings -lut 21600 "${KEYCHAIN_PATH}"
          security unlock-keychain -p "${{ secrets.COMMUNITY_MAC_CERTS_PASSWORD }}" "${KEYCHAIN_PATH}"
          security import certificate.p12 -k "${KEYCHAIN_PATH}" -P "${{ secrets.COMMUNITY_MAC_CERTS_PASSWORD }}" -T /usr/bin/codesign
          security set-key-partition-list -S apple-tool:,apple: -s -k "${{ secrets.COMMUNITY_MAC_CERTS_PASSWORD }}" "${KEYCHAIN_PATH}"
          security find-identity -v -p codesigning

      - name: Resolve macOS signing identity
        if: ${{ runner.os == 'macOS' }}
        shell: bash
        run: |
          set -euo pipefail
          SIGN_ID="${MAC_SIGNING_IDENTITY:-}"
          if [ -n "${SIGN_ID}" ] && ! security find-identity -v -p codesigning | grep -F "${SIGN_ID}" >/dev/null; then
            echo "Configured macOS signing identity not found: ${SIGN_ID}" >&2
            SIGN_ID=""
          fi
          if [ -z "${SIGN_ID}" ]; then
            SIGN_ID="$(security find-identity -v -p codesigning | awk -F '"' '/Developer ID Application/ { print $2; exit }')"
          fi
          if [ -z "${SIGN_ID}" ]; then
            echo "Error: no Developer ID Application signing identity found in keychain" >&2
            security find-identity -v -p codesigning || true
            exit 1
          fi
          echo "Using macOS signing identity: ${SIGN_ID}"
          echo "MAC_SIGNING_IDENTITY=${SIGN_ID}" >> "${GITHUB_ENV}"

      - name: Build Community desktop package
        shell: bash
        env:
          VERSION: ${{ needs.resolve.outputs.version }}
          TARGET: ${{ matrix.target }}
          COMMUNITY_RELEASE_EPOCH: ${{ needs.resolve.outputs.release_epoch }}
          COMMUNITY_UPDATE_KEY_ID: ${{ secrets.COMMUNITY_UPDATE_KEY_ID }}
          COMMUNITY_UPDATE_PUBLIC_KEY_B64: ${{ secrets.COMMUNITY_UPDATE_PUBLIC_KEY_B64 }}
        run: |
          set -euo pipefail
          test -n "${COMMUNITY_UPDATE_KEY_ID}"
          test -n "${COMMUNITY_UPDATE_PUBLIC_KEY_B64}"
          bash .community-packaging/script/package/package-community-jcef.sh "${VERSION}" "${TARGET}"

      - name: Notarize macOS package
        if: ${{ runner.os == 'macOS' }}
        timeout-minutes: 30
        shell: bash
        env:
          VERSION: ${{ needs.resolve.outputs.version }}
          ARTIFACT_NAME: ${{ matrix.artifact_name }}
          MAC_APPLE_ID: ${{ secrets.COMMUNITY_MAC_APPLE_ID }}
          MAC_APPLE_PASSWORD: ${{ secrets.COMMUNITY_MAC_APPLE_PASSWORD }}
          MAC_TEAM_ID: ${{ secrets.COMMUNITY_MAC_TEAM_ID }}
        run: |
          set -euo pipefail

          for required in MAC_APPLE_ID MAC_APPLE_PASSWORD MAC_TEAM_ID; do
            if [ -z "${!required}" ]; then
              echo "Missing required notarization secret: ${required}" >&2
              exit 1
            fi
          done

          case "${ARTIFACT_NAME}" in
            macos-arm64) dmg="jpackage/output/Chat2DB-Community-${VERSION}-arm64.dmg" ;;
            macos-x64) dmg="jpackage/output/Chat2DB-Community-${VERSION}-x64.dmg" ;;
            *)
              echo "Unexpected macOS artifact name: ${ARTIFACT_NAME}" >&2
              exit 1
              ;;
          esac
          test -s "${dmg}"

          submit_result="${RUNNER_TEMP}/notary-submit-${ARTIFACT_NAME}.json"
          wait_result="${RUNNER_TEMP}/notary-wait-${ARTIFACT_NAME}.json"
          submit_exit=0
          xcrun notarytool submit "${dmg}" \
            --apple-id "${MAC_APPLE_ID}" \
            --password "${MAC_APPLE_PASSWORD}" \
            --team-id "${MAC_TEAM_ID}" \
            --no-wait \
            --output-format json > "${submit_result}" || submit_exit=$?
          cat "${submit_result}"

          submission_id=$(/usr/bin/plutil -extract id raw -expect string -o - "${submit_result}" 2>/dev/null || true)
          if [ "${submit_exit}" -ne 0 ] || [ -z "${submission_id}" ]; then
            echo "Apple notarization submission failed: submission=${submission_id:-unknown}" >&2
            if [ -n "${submission_id}" ]; then
              xcrun notarytool log "${submission_id}" \
                --apple-id "${MAC_APPLE_ID}" \
                --password "${MAC_APPLE_PASSWORD}" \
                --team-id "${MAC_TEAM_ID}" || true
            fi
            exit 1
          fi

          echo "Apple notarization submission: ${submission_id}"
          wait_exit=0
          xcrun notarytool wait "${submission_id}" \
            --apple-id "${MAC_APPLE_ID}" \
            --password "${MAC_APPLE_PASSWORD}" \
            --team-id "${MAC_TEAM_ID}" \
            --timeout 25m \
            --output-format json > "${wait_result}" || wait_exit=$?
          cat "${wait_result}"

          notary_status=$(/usr/bin/plutil -extract status raw -expect string -o - "${wait_result}" 2>/dev/null || true)
          if [ "${wait_exit}" -ne 0 ] || [ "${notary_status}" != "Accepted" ]; then
            echo "Apple notarization failed: status=${notary_status:-unknown}, submission=${submission_id:-unknown}" >&2
            if ! xcrun notarytool log "${submission_id}" \
              --apple-id "${MAC_APPLE_ID}" \
              --password "${MAC_APPLE_PASSWORD}" \
              --team-id "${MAC_TEAM_ID}"; then
              echo "Unable to retrieve Apple notarization log for ${submission_id}" >&2
            fi
            exit 1
          fi

          xcrun stapler staple "${dmg}"
          xcrun stapler validate "${dmg}"

      - name: Install Windows signing client
        if: runner.os == 'Windows'
        shell: pwsh
        run: choco install winscp -y --no-progress

      - name: Sign Windows MSI
        if: runner.os == 'Windows'
        shell: pwsh
        env:
          VERSION: ${{ needs.resolve.outputs.version }}
          WIN_SERVER_IP: ${{ secrets.WIN_SERVER_IP }}
          WIN_SERVER_USER: ${{ secrets.WIN_SERVER_USER }}
          WIN_SSH_PRIVATE_KEY: ${{ secrets.WIN_SSH_PRIVATE_KEY }}
          REMOTE_SIGN_PATH: ${{ secrets.REMOTE_SIGN_PATH }}
          REMOTE_SIGN_SCRIPT: ${{ secrets.REMOTE_SIGN_SCRIPT }}
          HOST_KEY: ${{ secrets.HOST_KEY }}
        run: .community-packaging/script/package/sign_windows_package.ps1 -PackagePath "jpackage/output/Chat2DB-Community-$env:VERSION.msi"

      - name: Install Windows EXE packaging tools
        if: runner.os == 'Windows'
        shell: pwsh
        run: choco install innosetup -y --no-progress

      - name: Wrap Windows installer
        if: runner.os == 'Windows'
        shell: bash
        env:
          VERSION: ${{ needs.resolve.outputs.version }}
        run: bash .community-packaging/script/package/wrap_win_installer_community.sh "${VERSION}"

      - name: Sign Windows EXE
        if: runner.os == 'Windows'
        shell: pwsh
        env:
          VERSION: ${{ needs.resolve.outputs.version }}
          WIN_SERVER_IP: ${{ secrets.WIN_SERVER_IP }}
          WIN_SERVER_USER: ${{ secrets.WIN_SERVER_USER }}
          WIN_SSH_PRIVATE_KEY: ${{ secrets.WIN_SSH_PRIVATE_KEY }}
          REMOTE_SIGN_PATH: ${{ secrets.REMOTE_SIGN_PATH }}
          REMOTE_SIGN_SCRIPT: ${{ secrets.REMOTE_SIGN_SCRIPT }}
          HOST_KEY: ${{ secrets.HOST_KEY }}
        run: .community-packaging/script/package/sign_windows_package.ps1 -PackagePath "jpackage/output/Chat2DB-Community-$env:VERSION.exe"

      - name: Generate signed update packages
        shell: bash
        env:
          VERSION: ${{ needs.resolve.outputs.version }}
          RELEASE_EPOCH: ${{ needs.resolve.outputs.release_epoch }}
          BUILD_SHA: ${{ needs.resolve.outputs.build_sha }}
          ARTIFACT_NAME: ${{ matrix.artifact_name }}
          CHAT2DB_UPDATE_KEY_ID: ${{ secrets.COMMUNITY_UPDATE_KEY_ID }}
          CHAT2DB_UPDATE_PUBLIC_KEY_B64: ${{ secrets.COMMUNITY_UPDATE_PUBLIC_KEY_B64 }}
          CHAT2DB_UPDATE_SIGNING_PRIVATE_KEY_B64: ${{ secrets.COMMUNITY_UPDATE_SIGNING_PRIVATE_KEY_B64 }}
        run: |
          set -euo pipefail
          case "${ARTIFACT_NAME}" in
            macos-arm64) platform=MACOS; arch=ARM64 ;;
            macos-x64) platform=MACOS; arch=X64 ;;
            windows) platform=WINDOWS; arch=X64 ;;
            linux-x64) platform=LINUX; arch=X64 ;;
            linux-arm64) platform=LINUX; arch=ARM64 ;;
          esac
          if [ "${RUNNER_OS}" = macOS ]; then
            CHAT2DB_OPENSSL_BIN="$(brew --prefix openssl@3)/bin/openssl"
            export CHAT2DB_OPENSSL_BIN
          fi
          bash .community-packaging/script/package/prepare_community_update.sh "${VERSION}" "${RELEASE_EPOCH}" "${BUILD_SHA}" "${platform}" "${arch}"

      - name: Verify package metadata and record provenance
        shell: bash
        env:
          VERSION: ${{ needs.resolve.outputs.version }}
          NATIVE_VERSION: ${{ needs.resolve.outputs.native_version }}
          UPDATE_CHANNEL: ${{ needs.resolve.outputs.update_channel }}
          RELEASE_EPOCH: ${{ needs.resolve.outputs.release_epoch }}
          BUILD_SHA: ${{ needs.resolve.outputs.build_sha }}
          SOURCE_REF: ${{ inputs.source_ref || github.ref }}
          WORKFLOW_SHA: ${{ github.sha }}
        run: |
          set -euo pipefail
          test "$(git rev-parse HEAD)" = "${BUILD_SHA}"
          test "$(git -C .community-packaging rev-parse HEAD)" = "${WORKFLOW_SHA}"
          jq -e --arg version "${VERSION}" --arg sha "${BUILD_SHA}" --argjson epoch "${RELEASE_EPOCH}" \
            '.version == $version and .buildSha == $sha and .releaseEpoch == $epoch' \
            jpackage/input/sourceFile/version.json >/dev/null
          for manifest in jpackage/output/updates/manifest-*.json; do
            jq -e --arg version "${VERSION}" --arg native "${NATIVE_VERSION}" \
              --arg channel "${UPDATE_CHANNEL}" --arg sha "${BUILD_SHA}" --argjson epoch "${RELEASE_EPOCH}" \
              '.product == "COMMUNITY" and .version == $version and .nativeVersion == $native and
               .channel == $channel and .buildSha == $sha and .releaseEpoch == $epoch' "${manifest}" >/dev/null
          done
          jq -n --arg version "${VERSION}" --arg nativeVersion "${NATIVE_VERSION}" \
            --arg channel "${UPDATE_CHANNEL}" --arg sourceRef "${SOURCE_REF}" \
            --arg buildSha "${BUILD_SHA}" --arg workflowSha "${WORKFLOW_SHA}" \
            --argjson releaseEpoch "${RELEASE_EPOCH}" \
            '{version: $version, nativeVersion: $nativeVersion, channel: $channel,
              sourceRef: $sourceRef, buildSha: $buildSha, workflowSha: $workflowSha,
              releaseEpoch: $releaseEpoch}' > jpackage/output/build-provenance.json

      - name: Upload platform artifacts to GitHub Actions
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
        with:
          name: chat2db-community-${{ needs.resolve.outputs.version }}-${{ matrix.artifact_name }}
          if-no-files-found: error
          overwrite: true
          path: |
            jpackage/output/*.dmg
            jpackage/output/*.msi
            jpackage/output/*.exe
            jpackage/output/build-provenance.json
            jpackage/output/*.deb
            jpackage/output/*.rpm
            jpackage/output/*.AppImage
            jpackage/output/updates/*
            jpackage/input/sourceFile/version.json
            jpackage/input/sourceFile/*.jar
            jpackage/input/sourceFile/*.zip

  notify_summary:
    name: Notify Feishu Community beta build summary
    needs:
      - resolve
      - build
    if: ${{ always() && needs.resolve.outputs.channel == 'beta' }}
    environment: community-beta-signing
    runs-on: ubuntu-latest
    permissions:
      actions: read
    steps:
      - name: Send Feishu summary notification
        env:
          FEISHU_WEBHOOK_URL: ${{ secrets.COMMUNITY_FEISHU_RELEASE_NOTIFY_WEBHOOK }}
          VERSION: ${{ needs.resolve.outputs.version || inputs.version }}
          CREATE_RELEASE: ${{ needs.resolve.outputs.create_release }}
          BUILD_RESULT: ${{ needs.build.result }}
          RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
          GITHUB_TOKEN: ${{ github.token }}
        shell: python
        run: |
          import json
          import os
          import urllib.error
          import urllib.request

          webhook = os.environ.get("FEISHU_WEBHOOK_URL", "")
          if not webhook:
              print("FEISHU_RELEASE_NOTIFY_WEBHOOK is not configured; skip summary notification.")
              raise SystemExit(0)

          def failed_build_jobs():
              request = urllib.request.Request(
                  f"https://api.github.com/repos/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}/jobs?per_page=100",
                  headers={
                      "Accept": "application/vnd.github+json",
                      "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
                  },
              )
              try:
                  with urllib.request.urlopen(request, timeout=15) as response:
                      jobs = json.loads(response.read().decode("utf-8")).get("jobs", [])
              except Exception as exc:
                  return [f"- Failed to read job details: {exc}"]

              failed = []
              for job in jobs:
                  name = job.get("name", "")
                  conclusion = job.get("conclusion") or job.get("status") or "unknown"
                  if name.startswith("Build ") and conclusion != "success":
                      failed.append(f"- {name}: {conclusion}")
              return failed or [
                  "- No failed build job details were returned; see the workflow run."
              ]

          version = os.environ["VERSION"]
          build_result = os.environ["BUILD_RESULT"]
          run_url = os.environ["RUN_URL"]

          if build_result == "success":
              lines = [
                  "Chat2DB Community beta release completed",
                  f"Version: {version}",
                  f"Build result: {build_result}",
                  "Distribution: GitHub Beta release and Actions artifacts" if os.environ.get("CREATE_RELEASE") == "true" else "Distribution: GitHub Actions artifacts only",
                  f"Workflow run: {run_url}",
              ]
          else:
              lines = [
                  "Chat2DB Community beta build did not complete successfully",
                  f"Version: {version}",
                  f"Build result: {build_result}",
                  "Failed build jobs:",
                  *failed_build_jobs(),
                  f"Workflow run: {run_url}",
              ]

          payload = json.dumps(
              {"msg_type": "text", "content": {"text": "\n".join(lines)}},
              ensure_ascii=False,
          ).encode("utf-8")
          request = urllib.request.Request(
              webhook,
              data=payload,
              headers={"Content-Type": "application/json"},
              method="POST",
          )
          try:
              with urllib.request.urlopen(request, timeout=15) as response:
                  result = json.loads(response.read().decode("utf-8"))
              if result.get("code", 0) != 0:
                  print(f"::warning::Feishu summary notification was rejected: {result}")
          except urllib.error.URLError as exc:
              print(f"::warning::Feishu summary notification failed: {exc}")

  stage_release:
    name: Validate and stage GitHub Release
    needs:
      - resolve
      - build
    if: ${{ needs.resolve.outputs.create_release == 'true' }}
    # Stable and beta releases share the signing environment: the keys, the Windows
    # signing connection and the macOS notarization credentials are the same.
    environment: community-beta-signing
    runs-on: ubuntu-latest
    permissions:
      actions: read
      contents: write
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - name: Download platform artifacts
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
        with:
          pattern: chat2db-community-${{ needs.resolve.outputs.version }}-*
          path: downloaded-artifacts

      - name: Validate release assets and generate checksums
        shell: bash
        env:
          VERSION: ${{ needs.resolve.outputs.version }}
        run: |
          set -euo pipefail
          expected=(
            "Chat2DB-Community-${VERSION}-arm64.dmg"
            "Chat2DB-Community-${VERSION}-x64.dmg"
            "Chat2DB-Community-${VERSION}.msi"
            "Chat2DB-Community-${VERSION}-amd64.deb"
            "Chat2DB-Community-${VERSION}-arm64.deb"
            "Chat2DB-Community-${VERSION}-x86_64.rpm"
            "Chat2DB-Community-${VERSION}-aarch64.rpm"
            "Chat2DB-Community-${VERSION}-x86_64.AppImage"
            "Chat2DB-Community-${VERSION}-arm64.AppImage"
          )

          mapfile -d '' installers < <(
            find downloaded-artifacts -type f -not -path '*/updates/*' \
              \( -name '*.dmg' -o -name '*.msi' -o -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
              -print0
          )
          if [ "${#installers[@]}" -ne "${#expected[@]}" ]; then
            echo "Expected ${#expected[@]} installers, found ${#installers[@]}" >&2
            printf ' - %s\n' "${installers[@]}" >&2
            exit 1
          fi

          mkdir -p release-assets
          for asset in "${expected[@]}"; do
            mapfile -t matches < <(find downloaded-artifacts -type f -name "${asset}")
            if [ "${#matches[@]}" -ne 1 ]; then
              echo "Expected exactly one ${asset}, found ${#matches[@]}" >&2
              exit 1
            fi
            test -s "${matches[0]}"
            cp "${matches[0]}" "release-assets/${asset}"
          done

          (
            cd release-assets
            sha256sum "${expected[@]}" > SHA256SUMS
          )

      - name: Validate and collect update resources
        shell: bash
        env:
          VERSION: ${{ needs.resolve.outputs.version }}
          RELEASE_EPOCH: ${{ needs.resolve.outputs.release_epoch }}
          BUILD_SHA: ${{ needs.resolve.outputs.build_sha }}
          UPDATE_CHANNEL: ${{ needs.resolve.outputs.update_channel }}
        run: |
          set -euo pipefail
          while IFS= read -r -d '' asset; do
            destination="release-assets/$(basename "${asset}")"
            test ! -e "${destination}"
            cp "${asset}" "${destination}"
          done < <(find downloaded-artifacts -type f -path '*/updates/*' -print0)
          for manifest in release-assets/manifest-*.json; do
            jq -e --arg version "${VERSION}" --arg channel "${UPDATE_CHANNEL}" --arg sha "${BUILD_SHA}" --argjson epoch "${RELEASE_EPOCH}" \
              '.product == "COMMUNITY" and .channel == $channel and .version == $version and .buildSha == $sha and .releaseEpoch == $epoch' "${manifest}" >/dev/null
            package_url=$(jq -r '.packageUrl' "${manifest}")
            package="release-assets/${package_url##*/}"
            test "$(wc -c < "${package}" | tr -d ' ')" = "$(jq -r '.packageSize' "${manifest}")"
            test "$(sha256sum "${package}" | cut -d ' ' -f1)" = "$(jq -r '.packageSha256' "${manifest}")"
          done
          bash script/package/generate_update_index_v2.sh "${UPDATE_CHANNEL}" "${RELEASE_EPOCH}" \
            "https://github.com/OtterMind/Chat2DB/releases/download/v${VERSION}" \
            release-assets/release-index.json release-assets/manifest-*.json
          (cd release-assets && find . -maxdepth 1 -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum) > checksums.tmp
          mv checksums.tmp release-assets/SHA256SUMS

      - name: Upload validated release bundle
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
        with:
          name: community-release-bundle-${{ needs.resolve.outputs.version }}
          if-no-files-found: error
          overwrite: true
          path: release-assets/*

      - name: Create or refresh draft Release
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
          TAG_NAME: ${{ needs.resolve.outputs.tag_name }}
          VERSION: ${{ needs.resolve.outputs.version }}
          BUILD_SHA: ${{ needs.resolve.outputs.build_sha }}
          TAG_PUSH: ${{ needs.resolve.outputs.tag_push }}
        run: |
          set -euo pipefail
          RELEASE_TITLE="Chat2DB v${VERSION}"

          if draft=$(gh release view "${TAG_NAME}" --json isDraft --jq '.isDraft' 2>/dev/null); then
            if [ "${draft}" != "true" ]; then
              echo "Release ${TAG_NAME} is already published; refusing to replace it" >&2
              exit 1
            fi
            gh release edit "${TAG_NAME}" --title "${RELEASE_TITLE}"
            gh release upload "${TAG_NAME}" release-assets/* --clobber
          else
            release_args=("${TAG_NAME}" release-assets/* --draft --title "${RELEASE_TITLE}" --target "${BUILD_SHA}")
            # Only a tag push has a tag to verify; a manual Beta run creates the release itself.
            if [ "${TAG_PUSH}" = true ]; then
              release_args+=(--verify-tag)
            fi
            gh release create "${release_args[@]}" --generate-notes
          fi

          test "$(gh release view "${TAG_NAME}" --json name --jq '.name')" = "${RELEASE_TITLE}"
          find release-assets -maxdepth 1 -type f -exec basename {} \; | sort > expected-assets.txt
          gh release view "${TAG_NAME}" --json assets --jq '.assets[].name' | sort > actual-assets.txt
          diff -u expected-assets.txt actual-assets.txt

      - name: State that a Beta build is not the stable release
        if: ${{ needs.resolve.outputs.channel == 'beta' }}
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
          TAG_NAME: ${{ needs.resolve.outputs.tag_name }}
        run: |
          set -euo pipefail
          statement="**This is a Beta build, not the stable release.** The Latest release stays the stable one, and this build reaches clients only after Beta updates are enabled in the app; everyone else can install it from the files below."

          body=$(gh release view "${TAG_NAME}" --json body --jq '.body')
          if [[ "${body}" == "${statement}"* ]]; then
            echo "Beta statement already present on ${TAG_NAME}"
            exit 0
          fi

          printf '%s\n\n%s\n' "${statement}" "${body}" > beta-notes.md
          gh release edit "${TAG_NAME}" --notes-file beta-notes.md

  docker:
    name: Publish Docker image
    needs:
      - resolve
      - stage_release
    if: ${{ needs.resolve.outputs.publish == 'true' }}
    uses: ./.github/workflows/pushdocker.yml
    with:
      version: ${{ needs.resolve.outputs.version }}
      push_latest: true
    secrets:
      DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
      DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}

  publish_release:
    name: Publish GitHub Release
    needs:
      - resolve
      - stage_release
      - docker
    if: ${{ always() && needs.stage_release.result == 'success' && needs.resolve.outputs.create_release == 'true' && (needs.resolve.outputs.channel == 'beta' || needs.docker.result == 'success') }}
    # Stable and beta releases share the signing environment: the keys, the Windows
    # signing connection and the macOS notarization credentials are the same.
    environment: community-beta-signing
    runs-on: ubuntu-latest
    permissions:
      contents: write
    concurrency:
      group: community-update-publication-${{ needs.resolve.outputs.channel }}
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
      - name: Download validated Beta index
        if: ${{ needs.resolve.outputs.channel == 'beta' }}
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
        with:
          name: community-release-bundle-${{ needs.resolve.outputs.version }}
          path: release-assets
      - name: Publish validated Release
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
          TAG_NAME: ${{ needs.resolve.outputs.tag_name }}
          CHANNEL: ${{ needs.resolve.outputs.channel }}
        run: |
          set -euo pipefail
          # The Latest pointer follows the channel, not the release kind: a Beta build is a normal
          # release that must never take it away from the stable one.
          if [ "${CHANNEL}" = beta ]; then
            gh release edit "${TAG_NAME}" --draft=false --latest=false
          else
            gh release edit "${TAG_NAME}" --draft=false --latest
          fi
          test "$(gh release view "${TAG_NAME}" --json isDraft --jq '.isDraft')" = "false"
      - name: Update Beta channel pointer
        if: ${{ needs.resolve.outputs.channel == 'beta' }}
        shell: bash
        env:
          VERSION: ${{ needs.resolve.outputs.version }}
          CHANNEL_BRANCH: community-beta-index
        run: |
          set -euo pipefail
          test -s release-assets/release-index.json

          # A published release cannot have its assets replaced, so the Beta channel pointer
          # lives on a machine-owned branch that this workflow appends to.
          tip=""
          if git fetch --quiet origin "refs/heads/${CHANNEL_BRANCH}"; then
            tip=$(git rev-parse FETCH_HEAD)
          fi

          blob=$(git hash-object -w release-assets/release-index.json)
          tree=$(printf '100644 blob %s\trelease-index.json\n' "${blob}" | git mktree)
          if [ -n "${tip}" ] && [ "$(git rev-parse "${tip}^{tree}")" = "${tree}" ]; then
            echo "Beta channel pointer already carries ${VERSION}; nothing to publish."
            exit 0
          fi

          commit_args=(-m "chore(beta): publish ${VERSION} update index")
          if [ -n "${tip}" ]; then
            commit_args+=(-p "${tip}")
          fi
          commit=$(git \
            -c user.name='github-actions[bot]' \
            -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
            commit-tree "${tree}" "${commit_args[@]}")
          git push origin "${commit}:refs/heads/${CHANNEL_BRANCH}"
          echo "Published Beta channel pointer for ${VERSION}: ${commit}"