name: Rolling Release

on:
  push:
    branches:
      - main

# Serialize runs: the rolling `nightly` release is deleted and recreated at the
# end of each run, and overlapping runs could interleave those steps (or leave
# a checksums file describing another run's assets). Queue instead of cancel so
# an in-flight delete/create is never aborted halfway.
concurrency:
  group: rolling-release
  cancel-in-progress: false

permissions:
  contents: write
  security-events: write
  packages: read
  actions: read

env:
  TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
  TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}

jobs:
  security-scan:
    if: github.repository == 'zhom/donutbrowser'
    name: Security Vulnerability Scan
    uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@6e4298ebc4db23e847df9b2e2de2939d6f066c67" # v2.5.1
    with:
      scan-args: |-
        -r
        --skip-git
        --lockfile=pnpm-lock.yaml
        --lockfile=src-tauri/Cargo.lock
        ./
    permissions:
      security-events: write
      contents: read
      actions: read

  lint-js:
    if: github.repository == 'zhom/donutbrowser'
    name: Lint JavaScript/TypeScript
    uses: ./.github/workflows/lint-js.yml
    permissions:
      contents: read

  lint-rust:
    if: github.repository == 'zhom/donutbrowser'
    name: Lint Rust
    uses: ./.github/workflows/lint-rs.yml
    permissions:
      contents: read

  codeql:
    if: github.repository == 'zhom/donutbrowser'
    name: CodeQL
    uses: ./.github/workflows/codeql.yml
    permissions:
      security-events: write
      contents: read
      packages: read
      actions: read

  spellcheck:
    if: github.repository == 'zhom/donutbrowser'
    name: Spell Check
    uses: ./.github/workflows/spellcheck.yml
    permissions:
      contents: read

  rolling-release:
    if: github.repository == 'zhom/donutbrowser'
    needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
    permissions:
      contents: write
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: "macos-latest"
            args: "--target aarch64-apple-darwin --verbose"
            arch: "aarch64"
            target: "aarch64-apple-darwin"
            pkg_target: "latest-macos-arm64"
          - platform: "macos-latest"
            args: "--target x86_64-apple-darwin --verbose"
            arch: "x86_64"
            target: "x86_64-apple-darwin"
            pkg_target: "latest-macos-x64"
          - platform: "ubuntu-22.04"
            args: "--target x86_64-unknown-linux-gnu --verbose"
            arch: "x86_64"
            target: "x86_64-unknown-linux-gnu"
            pkg_target: "latest-linux-x64"
          - platform: "ubuntu-22.04-arm"
            args: "--target aarch64-unknown-linux-gnu --verbose"
            arch: "aarch64"
            target: "aarch64-unknown-linux-gnu"
            pkg_target: "latest-linux-arm64"
          - platform: "windows-latest"
            args: "--target x86_64-pc-windows-msvc --verbose"
            arch: "x86_64"
            target: "x86_64-pc-windows-msvc"
            pkg_target: "latest-win-x64"

    runs-on: ${{ matrix.platform }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1

      - name: Setup pnpm
        uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
        with:
          run_install: false

      - name: Setup Node.js
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
        with:
          node-version-file: .node-version
          cache: "pnpm"

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 #master
        with:
          toolchain: stable
          targets: ${{ matrix.target }}

      - name: Install dependencies (Ubuntu only)
        if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
        run: |
          sudo apt-get update
          sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils

      - name: Rust cache
        uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 #v2.9.2
        with:
          workdir: ./src-tauri

      - name: Install frontend dependencies
        run: pnpm install --frozen-lockfile

      - name: Build frontend
        # NEXT_PUBLIC_* vars are inlined at build time and must be forwarded
        # from secrets explicitly — they are NOT inherited from the job env.
        env:
          NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
        run: pnpm build

      - name: Verify frontend dist exists
        shell: bash
        run: |
          if [ ! -d "dist" ]; then
            echo "Error: dist directory not found after build"
            ls -la
            exit 1
          fi
          echo "Frontend dist directory verified at $(pwd)/dist"
          echo "Checking from src-tauri perspective:"
          ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"

      - name: Generate nightly timestamp
        id: timestamp
        shell: bash
        run: |
          # Committer date, not wall clock: every job in this run (including
          # update-nightly-release, which runs much later) must derive the
          # exact same tag, or a run straddling midnight UTC splits the
          # release from its checksums.
          TIMESTAMP=$(git show -s --format=%cs HEAD)
          COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
          echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
          echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"

      - name: Build sidecar binaries
        shell: bash
        working-directory: ./src-tauri
        env:
          BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
          GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
        run: |
          cargo build --bin donut-proxy --target ${{ matrix.target }} --release

      - name: Copy sidecar binaries to Tauri binaries
        shell: bash
        run: |
          mkdir -p src-tauri/binaries
          if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
            cp src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe src-tauri/binaries/donut-proxy-${{ matrix.target }}.exe
          else
            cp src-tauri/target/${{ matrix.target }}/release/donut-proxy src-tauri/binaries/donut-proxy-${{ matrix.target }}
            chmod +x src-tauri/binaries/donut-proxy-${{ matrix.target }}
          fi

      - name: Download verified Xray-core sidecar
        run: node src-tauri/download-xray.mjs --target ${{ matrix.target }}

      - name: Import Apple certificate
        if: matrix.platform == 'macos-latest'
        env:
          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
          APPLE_CERTIFICATE_KEY: ${{ secrets.APPLE_CERTIFICATE_KEY }}
        run: |
          CERT_PATH=$RUNNER_TEMP/cert.cer
          KEY_PATH=$RUNNER_TEMP/cert.key
          PEM_PATH=$RUNNER_TEMP/cert.pem
          P12_PATH=$RUNNER_TEMP/build_certificate.p12
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
          KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
          P12_PASSWORD=$(openssl rand -base64 32)

          echo "$APPLE_CERTIFICATE" | base64 --decode > $CERT_PATH
          echo "$APPLE_CERTIFICATE_KEY" | base64 --decode > $KEY_PATH

          openssl x509 -inform DER -in $CERT_PATH -out $PEM_PATH
          openssl pkcs12 -export -out $P12_PATH -inkey $KEY_PATH -in $PEM_PATH -passout pass:$P12_PASSWORD

          security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          security import $P12_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security list-keychain -d user -s $KEYCHAIN_PATH login.keychain-db

          echo "Available signing identities:"
          security find-identity -v -p codesigning $KEYCHAIN_PATH

          rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH

      - name: Build Tauri app
        uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f #v1.0.0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
          GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
          GITHUB_SHA: ${{ github.sha }}
          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          TARGET: ${{ matrix.target }}
          # tauri-action's inner `pnpm tauri build` re-runs beforeBuildCommand
          # which rebuilds dist/ in a subprocess. The env var must be here too.
          NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
        with:
          projectPath: ./src-tauri
          tagName: "nightly-${{ steps.timestamp.outputs.timestamp }}"
          releaseName: "Donut Browser Nightly (Build ${{ steps.timestamp.outputs.timestamp }})"
          releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.timestamp.outputs.timestamp }}"
          releaseDraft: false
          prerelease: true
          args: ${{ matrix.args }}

      - name: Create portable Windows ZIP
        if: matrix.platform == 'windows-latest'
        shell: bash
        run: |
          PORTABLE_DIR="Donut-Portable"
          mkdir -p "$PORTABLE_DIR"

          cp "src-tauri/target/${{ matrix.target }}/release/donutbrowser.exe" "$PORTABLE_DIR/Donut.exe"
          cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
          cp "src-tauri/binaries/xray-${{ matrix.target }}.exe" "$PORTABLE_DIR/xray.exe"
          mkdir -p "$PORTABLE_DIR/licenses"
          cp "src-tauri/binaries/xray-LICENSE.txt" "$PORTABLE_DIR/licenses/Xray-core-LICENSE.txt"
          # The daemon is currently disabled (no Cargo bin target), so it isn't
          # built. Copy it only if a build produced it, so the absent binary
          # doesn't fail the job.
          if [ -f "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" ]; then
            cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
          fi

          if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
            cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
          fi

          touch "$PORTABLE_DIR/.portable"

          7z a "Donut_x64-portable.zip" "$PORTABLE_DIR"

      - name: Upload portable ZIP to release
        if: matrix.platform == 'windows-latest'
        shell: bash
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NIGHTLY_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
        run: |
          gh release upload "$NIGHTLY_TAG" "Donut_x64-portable.zip" --clobber

      - name: Clean up Apple certificate
        if: matrix.platform == 'macos-latest' && always()
        run: |
          security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
          rm -f $RUNNER_TEMP/build_certificate.p12 || true

  update-nightly-release:
    if: github.repository == 'zhom/donutbrowser'
    needs: [rolling-release]
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1

      - name: Generate nightly tag
        id: tag
        run: |
          # Committer date — must match the tag the build matrix computed (see
          # the timestamp step there), even when this job runs past midnight.
          TIMESTAMP=$(git show -s --format=%cs HEAD)
          COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
          echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT

      - name: Generate nightly changelog
        id: nightly-changelog
        run: |
          LAST_STABLE=$(git tag --sort=-version:refname \
            | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+\$" \
            | head -n 1)

          if [ -z "$LAST_STABLE" ]; then
            LAST_STABLE=$(git rev-list --max-parents=0 HEAD)
          fi

          COMMIT_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
          {
            echo "**Nightly build from main branch**"
            echo ""
            echo "Commit: ${GITHUB_SHA}"
            echo "Changes since ${LAST_STABLE}:"
            echo ""
          } > /tmp/nightly-notes.md

          strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }

          features=""
          fixes=""
          refactors=""
          other=""

          while IFS= read -r msg; do
            [ -z "$msg" ] && continue
            case "$msg" in
              feat\(*\):*|feat:*)
                features="${features}- $(strip_prefix "$msg")"$'\n' ;;
              fix\(*\):*|fix:*)
                fixes="${fixes}- $(strip_prefix "$msg")"$'\n' ;;
              refactor\(*\):*|refactor:*)
                refactors="${refactors}- $(strip_prefix "$msg")"$'\n' ;;
              build*|ci*|chore*|test*|docs*|perf*)
                ;; # skip maintenance commits from nightly notes
              *)
                other="${other}- ${msg}"$'\n' ;;
            esac
          done < <(git log --pretty=format:"%s" "${LAST_STABLE}..HEAD" --no-merges)

          {
            [ -n "$features" ]  && printf "### Features\n\n%s\n" "$features"
            [ -n "$fixes" ]     && printf "### Bug Fixes\n\n%s\n" "$fixes"
            [ -n "$refactors" ] && printf "### Refactoring\n\n%s\n" "$refactors"
            [ -n "$other" ]     && printf "### Other\n\n%s\n" "$other"
            true
          } >> /tmp/nightly-notes.md

      - name: Update rolling nightly release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          NIGHTLY_TAG="${{ steps.tag.outputs.nightly_tag }}"
          ASSETS_DIR="/tmp/nightly-assets"

          # Download all assets from the per-commit nightly release
          mkdir -p "$ASSETS_DIR"
          gh release download "$NIGHTLY_TAG" --dir "$ASSETS_DIR" --clobber

          # Checksums for the per-commit release (original filenames). The app
          # self-updater downloads from per-commit nightly releases and refuses
          # to install anything it cannot verify against this file.
          # --repo is required: ASSETS_DIR is outside the git checkout, so gh
          # cannot infer the repository from the working directory.
          cd "$ASSETS_DIR"
          sha256sum Donut* > SHA256SUMS.txt
          gh release upload "$NIGHTLY_TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY"

          # Rename versioned filenames to stable nightly names
          for f in Donut_*_aarch64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.dmg; done
          for f in Donut_*_x64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_x64.dmg; done
          for f in Donut_*_x64-setup.exe; do [ -f "$f" ] && mv "$f" Donut_nightly_x64-setup.exe; done
          for f in Donut_*_aarch64.AppImage; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.AppImage; done
          for f in Donut_*_amd64.AppImage; do [ -f "$f" ] && mv "$f" Donut_nightly_amd64.AppImage; done
          for f in Donut_*_amd64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_amd64.deb; done
          for f in Donut_*_arm64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_arm64.deb; done
          for f in Donut-*.x86_64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_x86_64.rpm; done
          for f in Donut-*.aarch64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.rpm; done
          for f in Donut_*_aarch64.app.tar.gz; do [ -f "$f" ] && mv "$f" Donut_aarch64.app.tar.gz; done
          for f in Donut_*_x64.app.tar.gz; do [ -f "$f" ] && mv "$f" Donut_x64.app.tar.gz; done

          # Checksums for the rolling release (renamed filenames), restricted
          # to exactly the assets uploaded below.
          sha256sum Donut_nightly_* Donut_aarch64.app.tar.gz Donut_x64.app.tar.gz > SHA256SUMS.txt
          cd "$GITHUB_WORKSPACE"

          # Delete existing rolling nightly release and tag
          gh release delete nightly --yes 2>/dev/null || true
          git push --delete origin nightly 2>/dev/null || true

          # Create new rolling nightly release with all assets
          gh release create nightly \
            "$ASSETS_DIR"/Donut_nightly_* \
            "$ASSETS_DIR"/Donut_aarch64.app.tar.gz \
            "$ASSETS_DIR"/Donut_x64.app.tar.gz \
            "$ASSETS_DIR"/SHA256SUMS.txt \
            --title "Donut Browser Nightly" \
            --notes-file /tmp/nightly-notes.md \
            --prerelease

  deploy-website:
    if: github.repository == 'zhom/donutbrowser'
    needs: [update-nightly-release]
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Cloudflare Pages deployment
        env:
          DEPLOYMENT_HOOK: ${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}
        run: curl -fsSL -X POST "$DEPLOYMENT_HOOK"

  notify-discord:
    if: github.repository == 'zhom/donutbrowser'
    needs: [update-nightly-release]
    runs-on: ubuntu-latest
    steps:
      - name: Send Discord notification
        env:
          DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_NIGHTLY_WEBHOOK_URL }}
        run: |
          COMMIT_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
          RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/nightly"
          COMMIT_URL="https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}"

          PAYLOAD=$(jq -n \
            --arg title "Donut Browser Nightly (${COMMIT_SHORT})" \
            --arg url "$RELEASE_URL" \
            --arg commit_url "$COMMIT_URL" \
            --arg commit_short "$COMMIT_SHORT" \
            '{
              embeds: [{
                title: $title,
                url: $url,
                color: 16752128,
                fields: [
                  { name: "Commit", value: ("[" + $commit_short + "](" + $commit_url + ")"), inline: true },
                  { name: "Download", value: ("[Nightly Release](" + $url + ")"), inline: true }
                ],
                footer: { text: "donutbrowser.com" }
              }]
            }')

          curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
