name: Publish npm

on:
  push:
    tags:
      - '*.*.*'
  workflow_dispatch:
  schedule:
    - cron: '17 3 * * *'

permissions:
  contents: read
  id-token: write

jobs:
  metadata:
    name: Validate metadata
    runs-on: ubuntu-latest
    outputs:
      should_publish: ${{ steps.version.outputs.should_publish }}
      tag: ${{ steps.version.outputs.tag }}
      version: ${{ steps.version.outputs.version }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 24

      - name: Check latest tag against npm
        id: version
        run: |
          node <<'NODE'
          const { execFileSync, execSync } = require('node:child_process');
          const fs = require('node:fs');

          function parseVersion(value) {
            const match = value.match(/^v?(\d+)\.(\d+)\.(\d+)$/);
            if (!match) {
              return null;
            }
            return {
              major: Number(match[1]),
              minor: Number(match[2]),
              patch: Number(match[3]),
              version: `${match[1]}.${match[2]}.${match[3]}`,
            };
          }

          function compareVersions(a, b) {
            for (const key of ['major', 'minor', 'patch']) {
              if (a[key] !== b[key]) {
                return a[key] - b[key];
              }
            }
            return 0;
          }

          function tagHasFile(tag, file) {
            try {
              execFileSync('git', ['cat-file', '-e', `${tag}:${file}`], {
                stdio: ['ignore', 'ignore', 'ignore'],
              });
              return true;
            } catch {
              return false;
            }
          }

          function isPublishableTag({ tag }) {
            return [
              'Cargo.toml',
              'package.json',
              'npm/darwin-arm64/package.json',
              'npm/linux-x64/package.json',
              'npm/win32-x64/package.json',
            ].every((file) => tagHasFile(tag, file));
          }

          function readTagFile(tag, file) {
            return execFileSync('git', ['show', `${tag}:${file}`], {
              encoding: 'utf8',
              stdio: ['ignore', 'pipe', 'inherit'],
            });
          }

          function npmPackageVersion(packageName, packageVersion) {
            try {
              return execFileSync('npm', ['view', `${packageName}@${packageVersion}`, 'version', '--json'], {
                encoding: 'utf8',
                stdio: ['ignore', 'pipe', 'pipe'],
              }).trim().replace(/^"|"$/g, '');
            } catch (error) {
              const output = `${error.stdout ?? ''}\n${error.stderr ?? ''}`;
              if (output.includes('E404')) {
                return null;
              }
              throw error;
            }
          }

          const tags = execSync('git tag --list', { encoding: 'utf8' })
            .trim()
            .split('\n')
            .filter(Boolean)
            .map((tag) => ({ tag, parsed: parseVersion(tag) }))
            .filter(({ parsed }) => parsed)
            .sort((a, b) => compareVersions(a.parsed, b.parsed));

          const publishableTags = tags.filter(isPublishableTag);
          if (publishableTags.length === 0) {
            throw new Error('no version tag contains npm package metadata');
          }

          const latest = publishableTags.at(-1);
          const packageFiles = [
            'package.json',
            'npm/darwin-arm64/package.json',
            'npm/linux-x64/package.json',
            'npm/win32-x64/package.json',
          ];
          const packages = packageFiles.map((file) => JSON.parse(readTagFile(latest.tag, file)));
          const missingPackages = packages.filter((data) =>
            npmPackageVersion(data.name, data.version) !== data.version
          );

          const shouldPublish = missingPackages.length > 0;
          fs.appendFileSync(process.env.GITHUB_OUTPUT, `should_publish=${shouldPublish}\n`);
          fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=${latest.tag}\n`);
          fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${latest.parsed.version}\n`);

          console.log(`latest git tag: ${latest.tag}`);
          if (missingPackages.length > 0) {
            console.log(`missing npm packages: ${missingPackages.map(({ name, version }) => `${name}@${version}`).join(', ')}`);
          } else {
            console.log('all npm packages are already published');
          }
          console.log(`should publish: ${shouldPublish}`);
          NODE

      - name: Validate package metadata
        run: |
          node <<'NODE'
          const { execFileSync } = require('node:child_process');

          const tag = process.env.TAG_REF;

          function readTagFile(file) {
            return execFileSync('git', ['show', `${tag}:${file}`], {
              encoding: 'utf8',
              stdio: ['ignore', 'pipe', 'inherit'],
            });
          }

          const packageDirs = [
            'npm/darwin-arm64',
            'npm/linux-x64',
            'npm/win32-x64',
          ];
          const root = JSON.parse(readTagFile('package.json'));
          const cargoToml = readTagFile('Cargo.toml');
          const cargoVersion = cargoToml.match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1];

          if (!cargoVersion) {
            throw new Error('cannot find Cargo.toml package version');
          }

          if (root.version !== cargoVersion) {
            throw new Error(`package.json version ${root.version} does not match Cargo.toml ${cargoVersion}`);
          }

          const tagVersion = process.env.TAG_VERSION;
          if (root.version !== tagVersion) {
            throw new Error(`package.json version ${root.version} does not match latest tag ${tagVersion}`);
          }

          for (const packageDir of packageDirs) {
            const data = JSON.parse(readTagFile(`${packageDir}/package.json`));
            if (data.version !== root.version) {
              throw new Error(`${packageDir}/package.json version ${data.version} does not match ${root.version}`);
            }
            if (root.optionalDependencies?.[data.name] !== root.version) {
              throw new Error(`package.json optional dependency ${data.name} does not match ${root.version}`);
            }
          }
          NODE
        env:
          TAG_REF: ${{ steps.version.outputs.tag }}
          TAG_VERSION: ${{ steps.version.outputs.version }}

  build:
    name: Build ${{ matrix.package }}
    needs: metadata
    runs-on: ${{ matrix.runner }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - package: darwin-arm64
            runner: macos-latest
            package_dir: npm/darwin-arm64
            binary: readseek
          - package: linux-x64
            runner: ubuntu-latest
            package_dir: npm/linux-x64
            binary: readseek
          - package: win32-x64
            runner: windows-2025
            package_dir: npm/win32-x64
            binary: readseek.exe

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ needs.metadata.outputs.tag }}

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      # A fully static (crt-static) glibc binary has no dynamic dependencies, so
      # it runs on any Linux distribution (glibc or musl). onnxruntime is linked
      # in statically by `ort`; the Florence-2 model is fetched by build.rs.
      - name: Build (Linux static-glibc)
        if: runner.os == 'Linux'
        env:
          RUSTFLAGS: "-C target-feature=+crt-static"
        run: cargo build --release --target x86_64-unknown-linux-gnu

      - name: Build (macOS / Windows)
        if: runner.os != 'Linux'
        run: cargo build --release

      - name: Stage binary
        shell: bash
        run: |
          set -euo pipefail
          if [[ "${{ runner.os }}" == "Linux" ]]; then
            bin_path="target/x86_64-unknown-linux-gnu/release/readseek"
          else
            bin_path="target/release/readseek"
          fi
          if [[ "${{ matrix.binary }}" == *.exe ]]; then
            bin_path="target/release/readseek.exe"
          fi
          mkdir -p "${{ matrix.package_dir }}/bin"
          cp "$bin_path" "${{ matrix.package_dir }}/bin/${{ matrix.binary }}"
          if [[ "${{ matrix.binary }}" != *.exe ]]; then
            chmod 755 "${{ matrix.package_dir }}/bin/${{ matrix.binary }}"
          fi
          mkdir -p artifacts
          tar -C "${{ matrix.package_dir }}/bin" -czf \
            "artifacts/${{ matrix.package }}.tar.gz" "${{ matrix.binary }}"

      - name: Upload binary
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.package }}
          path: artifacts/${{ matrix.package }}.tar.gz
          if-no-files-found: error

  publish:
    name: Publish packages
    needs: [metadata, build]
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ needs.metadata.outputs.tag }}

      - name: Install Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org

      - name: Verify npm authentication
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: npm whoami

      - name: Download darwin-arm64 binary
        uses: actions/download-artifact@v4
        with:
          name: darwin-arm64
          path: artifacts/darwin-arm64

      - name: Download linux-x64 binary
        uses: actions/download-artifact@v4
        with:
          name: linux-x64
          path: artifacts/linux-x64

      - name: Download win32-x64 binary
        uses: actions/download-artifact@v4
        with:
          name: win32-x64
          path: artifacts/win32-x64

      - name: Stage package binaries
        shell: bash
        run: |
          set -euo pipefail
          mkdir -p npm/darwin-arm64/bin npm/linux-x64/bin npm/win32-x64/bin
          tar -xzf artifacts/darwin-arm64/darwin-arm64.tar.gz -C npm/darwin-arm64/bin
          tar -xzf artifacts/linux-x64/linux-x64.tar.gz -C npm/linux-x64/bin
          tar -xzf artifacts/win32-x64/win32-x64.tar.gz -C npm/win32-x64/bin
          chmod 755 npm/darwin-arm64/bin/readseek
          chmod 755 npm/linux-x64/bin/readseek
          test -f npm/win32-x64/bin/readseek.exe

      - name: Publish npm packages
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          publish_if_missing() {
            package_dir="$1"
            package_name="$(node -p "require('./${package_dir}/package.json').name")"
            package_version="$(node -p "require('./${package_dir}/package.json').version")"
            if npm view "${package_name}@${package_version}" version >/dev/null 2>&1; then
              echo "${package_name}@${package_version} is already published"
              return 0
            fi
            npm publish "./${package_dir}" --access public --provenance
          }
          publish_if_missing npm/darwin-arm64
          publish_if_missing npm/linux-x64
          publish_if_missing npm/win32-x64
          publish_if_missing .

  github-release:
    name: Create GitHub Release
    needs: [metadata, build]
    runs-on: ubuntu-latest
    permissions:
      contents: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Download darwin-arm64 binary
        uses: actions/download-artifact@v4
        with:
          name: darwin-arm64
          path: artifacts/darwin-arm64

      - name: Download linux-x64 binary
        uses: actions/download-artifact@v4
        with:
          name: linux-x64
          path: artifacts/linux-x64

      - name: Download win32-x64 binary
        uses: actions/download-artifact@v4
        with:
          name: win32-x64
          path: artifacts/win32-x64

      - name: Prepare release assets
        shell: bash
        run: |
          set -euo pipefail
          ver="${{ needs.metadata.outputs.version }}"
          mkdir -p assets
          mv artifacts/darwin-arm64/darwin-arm64.tar.gz \
            "assets/readseek-${ver}-darwin-arm64.tar.gz"
          mv artifacts/linux-x64/linux-x64.tar.gz \
            "assets/readseek-${ver}-linux-x64.tar.gz"
          mv artifacts/win32-x64/win32-x64.tar.gz \
            "assets/readseek-${ver}-win32-x64.tar.gz"

      - name: Create GitHub Release
        shell: bash
        run: |
          if gh release view "${{ needs.metadata.outputs.tag }}" &>/dev/null; then
            echo "Release already exists, skipping creation"
            exit 0
          fi
          gh release create "${{ needs.metadata.outputs.tag }}" \
            --title "readseek ${{ needs.metadata.outputs.version }}" \
            --generate-notes \
            assets/readseek-*.tar.gz
        env:
          GH_TOKEN: ${{ github.token }}
