name: Release

on:
  push:
    branches:
      - main
  workflow_dispatch:
    inputs:
      tag:
        description: Existing release tag to rebuild or republish, for example v0.3.12
        required: false
        type: string

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

permissions:
  actions: write
  contents: write
  issues: write
  pull-requests: write

jobs:
  verify-main-push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - uses: actions/setup-python@v6
        with:
          python-version: '3.12'

      - name: Free disk space for Rust builds
        run: bash execution/free_runner_disk.sh

      - name: Validate commit subjects for this push
        if: github.event_name == 'push'
        run: python execution/conventional_commits.py check-push-range "${{ github.event.before }}" "$GITHUB_SHA"

      - name: Validate manual run HEAD commit subject
        if: github.event_name == 'workflow_dispatch'
        run: python execution/conventional_commits.py check-range "HEAD^!"

      - name: Run version sync tests
        run: python -m unittest discover -s execution -p 'test_*.py'

      # rust-toolchain.toml is the single source of truth for the toolchain
      # version; rustup resolves it natively (channel + components).
      - name: Install Rust toolchain (rust-toolchain.toml)
        run: rustup toolchain install

      - name: Run cargo check
        run: cargo check

      - name: Clone phase0 golden-replay corpora
        run: bash execution/clone_phase0_corpora.sh

      - name: Run Rust tests
        run: cargo test --all-targets -- --test-threads=1

      - name: Build release binary on main pushes
        run: cargo build --release

      # Engine-only build for library embedders (e.g. AAP). ci.yml runs the same
      # gate on PRs; this mirrors it on main pushes so a server-only break (e.g. a
      # `#[cfg(unix)]` path cfg'd out on the contributor's OS) can never reach an
      # embedder via main.
      - name: Build engine-only embed feature
        run: cargo build --no-default-features --features embed

      - name: Run embed-feature lib tests
        run: cargo test --no-default-features --features embed --lib -- --test-threads=1

      - uses: actions/setup-node@v6
        with:
          node-version: 20

      - name: Test npm package
        working-directory: npm
        run: npm test

      # Mirror ci.yml's tool-correctness gate onto the release path. ci.yml gates
      # PRs; without this a harness regression that lands via a non-PR push (or a
      # release-please merge) could reach a cut release unverified. Reuses the
      # Node set up above and the release binary built at "Build release binary
      # on main pushes". ripgrep backs the harness grep oracle (not on ubuntu).
      - name: Install ripgrep (harness grep oracle)
        run: sudo apt-get update && sudo apt-get install -y ripgrep

      - name: Verify tool correctness (both fixtures)
        run: |
          node scripts/verify-tools.cjs --bin target/release/symforge
          node scripts/verify-tools.cjs --fixture verify-tools-real --bin target/release/symforge

  prepare-release:
    needs: verify-main-push
    runs-on: ubuntu-latest
    outputs:
      release_created: ${{ steps.context.outputs.release_created }}
      tag_name: ${{ steps.context.outputs.tag_name }}
      version: ${{ steps.context.outputs.version }}
      release_ref: ${{ steps.context.outputs.release_ref }}
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0

      - uses: actions/setup-python@v6
        with:
          python-version: '3.12'

      - name: Run version sync tests
        run: python -m unittest discover -s execution -p 'test_*.py'

      - id: release
        if: ${{ github.event_name != 'workflow_dispatch' || inputs.tag == '' }}
        uses: googleapis/release-please-action@v5
        with:
          token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
          include-component-in-tag: false
          config-file: .github/release-please-config.json
          manifest-file: .github/.release-please-manifest.json

      - name: Auto-merge release PR
        if: ${{ steps.release.outputs.prs_created == 'true' }}
        env:
          GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
          GH_REPO: ${{ github.repository }}
        run: |
          # Wait for PR label to propagate, then find the release PR
          for attempt in 1 2 3 4 5 6; do
            sleep 10
            PR=$(gh pr list --label "autorelease: pending" --json number --jq '.[0].number // empty')
            if [[ -n "$PR" ]]; then
              break
            fi
            echo "Attempt $attempt: no pending release PR found yet, retrying..."
          done
          if [[ -z "$PR" ]]; then
            echo "::warning::No pending release PR found after 60s — skipping auto-merge"
            exit 0
          fi
          echo "Auto-merging release PR #$PR"
          gh pr merge "$PR" --merge --delete-branch
          # The PAT-powered merge triggers a new workflow run where
          # release-please creates the release + tag, then build proceeds.

      - id: context
        shell: bash
        env:
          MANUAL_TAG: ${{ inputs.tag }}
          RELEASE_CREATED: ${{ steps.release.outputs.release_created }}
          RELEASE_TAG: ${{ steps.release.outputs.tag_name }}
          RELEASE_VERSION: ${{ steps.release.outputs.version }}
        run: |
          git fetch --tags --force

          CANONICAL_TAG_RE='^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
          TAG_NAME=""
          VERSION=""

          if [[ -n "$MANUAL_TAG" ]]; then
            if [[ ! "$MANUAL_TAG" =~ $CANONICAL_TAG_RE ]]; then
              echo "::error::Manual rebuild tags must use canonical vX.Y.Z format. Received '$MANUAL_TAG'."
              exit 1
            fi
            TAG_NAME="$MANUAL_TAG"
          elif [[ "$RELEASE_CREATED" == "true" ]]; then
            if [[ -z "$RELEASE_VERSION" ]]; then
              echo "::error::release-please did not return a version for the created release."
              exit 1
            fi
            TAG_NAME="$RELEASE_TAG"
            VERSION="$RELEASE_VERSION"
            EXPECTED_TAG="v$RELEASE_VERSION"
            if [[ "$TAG_NAME" != "$EXPECTED_TAG" ]]; then
              echo "::error::release-please returned tag '$TAG_NAME' but the canonical tag for this repository is '$EXPECTED_TAG'."
              exit 1
            fi
          else
            TAG_NAME="$(git tag --points-at "$GITHUB_SHA" 'v*' | sort -V | tail -n1)"
          fi

          if [[ -n "$TAG_NAME" ]]; then
            if [[ ! "$TAG_NAME" =~ $CANONICAL_TAG_RE ]]; then
              echo "::error::Resolved release tag '$TAG_NAME' does not match canonical vX.Y.Z format."
              exit 1
            fi

            if ! git rev-parse -q --verify "refs/tags/$TAG_NAME" >/dev/null; then
              echo "::error::Tag '$TAG_NAME' does not exist in this repository."
              exit 1
            fi

            if [[ -z "$VERSION" ]]; then
              VERSION="${TAG_NAME#v}"
            fi

            echo "release_created=true" >> "$GITHUB_OUTPUT"
            echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
            echo "version=$VERSION" >> "$GITHUB_OUTPUT"
            echo "release_ref=$TAG_NAME" >> "$GITHUB_OUTPUT"
          else
            echo "release_created=false" >> "$GITHUB_OUTPUT"
            echo "release_ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
          fi

  build:
    needs: prepare-release
    if: needs.prepare-release.outputs.release_created == 'true'
    strategy:
      matrix:
        include:
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            artifact: symforge-windows-x64.exe
            binary: target/x86_64-pc-windows-msvc/release/symforge.exe
          - os: ubuntu-latest
            target: x86_64-unknown-linux-gnu
            artifact: symforge-linux-x64
            binary: target/x86_64-unknown-linux-gnu/release/symforge
          - os: macos-latest
            target: aarch64-apple-darwin
            artifact: symforge-macos-arm64
            binary: target/aarch64-apple-darwin/release/symforge
          - os: macos-latest
            target: x86_64-apple-darwin
            artifact: symforge-macos-x64
            binary: target/x86_64-apple-darwin/release/symforge

    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ needs.prepare-release.outputs.release_ref }}

      - uses: actions/setup-python@v6
        with:
          python-version: '3.12'

      - name: Verify release versions
        run: python execution/version_sync.py check --tag "${{ needs.prepare-release.outputs.tag_name }}"

      # rust-toolchain.toml is the single source of truth for the toolchain
      # version; rustup resolves it natively (channel + components).
      - name: Install Rust toolchain (rust-toolchain.toml)
        shell: bash
        run: rustup toolchain install && rustup target add ${{ matrix.target }}

      - name: Build release binary
        run: cargo build --release --target ${{ matrix.target }}

      - name: Rename artifact
        shell: bash
        run: cp "${{ matrix.binary }}" "${{ matrix.artifact }}"

      - name: Upload artifact
        uses: actions/upload-artifact@v7
        with:
          name: ${{ matrix.artifact }}
          path: ${{ matrix.artifact }}
          if-no-files-found: error

  build-npm-package:
    needs:
      - prepare-release
      - build
    if: needs.prepare-release.outputs.release_created == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ needs.prepare-release.outputs.release_ref }}

      - uses: actions/setup-python@v6
        with:
          python-version: '3.12'

      - name: Verify release versions
        run: python execution/version_sync.py check --tag "${{ needs.prepare-release.outputs.tag_name }}"

      - uses: actions/setup-node@v6
        with:
          node-version: 20

      - name: Synchronize npm package versions
        env:
          VERSION: ${{ needs.prepare-release.outputs.version }}
        run: |
          node <<'NODE'
          const fs = require('fs');
          const version = process.env.VERSION;
          const platformPackages = {
            'linux-x64': 'symforge-linux-x64',
            'macos-arm64': 'symforge-macos-arm64',
            'macos-x64': 'symforge-macos-x64',
            'windows-x64': 'symforge-windows-x64',
          };

          const rootPath = 'npm/package.json';
          const root = JSON.parse(fs.readFileSync(rootPath, 'utf8'));
          root.version = version;
          root.optionalDependencies = Object.fromEntries(
            Object.values(platformPackages).map((name) => [name, version]),
          );
          fs.writeFileSync(rootPath, JSON.stringify(root, null, 2) + '\n');

          for (const [dir, name] of Object.entries(platformPackages)) {
            const packagePath = `npm/platforms/${dir}/package.json`;
            const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
            pkg.name = name;
            pkg.version = version;
            fs.writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n');
          }
          NODE

      - name: Test npm package
        working-directory: npm
        run: npm test

      - name: Download built binary artifacts
        uses: actions/download-artifact@v8
        with:
          pattern: symforge-*
          path: dist-binaries
          merge-multiple: true

      - name: Prepare platform npm packages
        shell: bash
        run: |
          set -euo pipefail

          mkdir -p npm/platforms/windows-x64/bin
          mkdir -p npm/platforms/linux-x64/bin
          mkdir -p npm/platforms/macos-x64/bin
          mkdir -p npm/platforms/macos-arm64/bin

          cp dist-binaries/symforge-windows-x64.exe npm/platforms/windows-x64/bin/symforge.exe
          cp dist-binaries/symforge-linux-x64 npm/platforms/linux-x64/bin/symforge
          cp dist-binaries/symforge-macos-x64 npm/platforms/macos-x64/bin/symforge
          cp dist-binaries/symforge-macos-arm64 npm/platforms/macos-arm64/bin/symforge

          chmod 755 npm/platforms/linux-x64/bin/symforge
          chmod 755 npm/platforms/macos-x64/bin/symforge
          chmod 755 npm/platforms/macos-arm64/bin/symforge

          for dir in npm/platforms/*; do
            cp LICENSE "$dir/LICENSE"
          done

      - name: Build npm tarballs
        shell: bash
        run: |
          set -euo pipefail

          mkdir -p dist-npm
          npm pack ./npm --pack-destination dist-npm
          for dir in npm/platforms/*; do
            npm pack "$dir" --pack-destination dist-npm
          done
          ls -l dist-npm

      - name: Upload npm package artifact
        uses: actions/upload-artifact@v7
        with:
          name: npm-package
          path: dist-npm/*.tgz
          if-no-files-found: error

  upload-release-assets:
    needs:
      - prepare-release
      - build
      - build-npm-package
    if: needs.prepare-release.outputs.release_created == 'true'
    runs-on: ubuntu-latest
    steps:
      - name: Download all artifacts
        uses: actions/download-artifact@v8
        with:
          path: artifacts
          merge-multiple: true

      - name: Upload assets to GitHub release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_REPO: ${{ github.repository }}
        run: gh release upload "${{ needs.prepare-release.outputs.tag_name }}" artifacts/* --clobber

  cargo-publish:
    needs:
      - prepare-release
      - build
      - build-npm-package
      - upload-release-assets
    if: needs.prepare-release.outputs.release_created == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ needs.prepare-release.outputs.release_ref }}

      - uses: actions/setup-python@v6
        with:
          python-version: '3.12'

      # rust-toolchain.toml is the single source of truth for the toolchain
      # version; rustup resolves it natively (channel + components).
      - name: Install Rust toolchain (rust-toolchain.toml)
        run: rustup toolchain install

      - name: Publish to crates.io
        run: >
          python execution/release_ops.py publish-cargo
          --crate-name symforge
          --version "${{ needs.prepare-release.outputs.version }}"
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

  npm-publish:
    needs:
      - prepare-release
      - build-npm-package
      - upload-release-assets
    if: needs.prepare-release.outputs.release_created == 'true'
    runs-on: ubuntu-latest
    environment: npm-publish
    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ needs.prepare-release.outputs.release_ref }}

      - uses: actions/setup-python@v6
        with:
          python-version: '3.12'

      - uses: actions/setup-node@v6
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org

      - name: Download npm package artifact
        uses: actions/download-artifact@v8
        with:
          name: npm-package
          path: dist

      - name: Verify npm package artifact
        shell: bash
        env:
          VERSION: ${{ needs.prepare-release.outputs.version }}
        run: |
          set -euo pipefail
          for package in \
            symforge-windows-x64 \
            symforge-linux-x64 \
            symforge-macos-x64 \
            symforge-macos-arm64 \
            symforge
          do
            test -f "./dist/${package}-${VERSION}.tgz"
          done
          ls -l dist

      - name: Publish platform packages to npm
        shell: bash
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
          VERSION: ${{ needs.prepare-release.outputs.version }}
        run: |
          set -euo pipefail
          for package in \
            symforge-windows-x64 \
            symforge-linux-x64 \
            symforge-macos-x64 \
            symforge-macos-arm64
          do
            python execution/release_ops.py publish-npm \
              --tarball "./dist/${package}-${VERSION}.tgz" \
              --package-name "$package" \
              --version "$VERSION"
          done

      - name: Publish root package to npm
        run: >
          python execution/release_ops.py publish-npm
          --tarball "./dist/symforge-${{ needs.prepare-release.outputs.version }}.tgz"
          --package-name symforge
          --version "${{ needs.prepare-release.outputs.version }}"
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
