name: CI

on:
  push:
    tags:
      - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
    branches:
      - "main"
    paths-ignore:
      - "docs/**"
      - "README.md"
      - ".github/workflows/release.yml"
      - ".github/workflows/official-site.yml"
  pull_request:
    branches:
      - "main"
    paths-ignore:
      - "docs/**"
      - "README.md"
      - ".github/workflows/release.yml"
      - ".github/workflows/official-site.yml"

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  CARGO_TERM_COLOR: always
  HURL_VERSION: 8.0.0
  REGISTRY_USERNAME: lovasoa
  REGISTRY_IMAGE: lovasoa/sqlpage

jobs:
  compile_and_lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: npm ci
      - run: npm test
      - name: Set up cargo cache
        uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
        env:
          NODE_OPTIONS: --no-deprecation
        with:
          shared-key: rust-sqlpage-proj-test
          save-if: ${{ github.ref == 'refs/heads/main' }}
      - run: cargo fmt --all -- --check
      - run: cargo clippy --all-targets --all-features -- -D warnings
      # The database matrix below runs the same Rust test harnesses against
      # different DATABASE_URL values. Package the Linux test executables once
      # here so those jobs do not recompile SQLPage or its dependencies.
      - name: Package Linux Rust test binaries
        run: |
          set -euo pipefail
          rm -rf target/sqlpage-test-binaries
          mkdir -p target/sqlpage-test-binaries
          cargo test --features odbc-static --no-run --message-format=json \
            | jq -r 'select(.profile.test == true and .executable != null) | .executable' \
            | while IFS= read -r test_binary; do
                cp -- "$test_binary" target/sqlpage-test-binaries/
              done
          test -n "$(find target/sqlpage-test-binaries -maxdepth 1 -type f -print -quit)"
          tar -C target/sqlpage-test-binaries -czf target/sqlpage-linux-test-binaries.tar.gz .
      - name: Build Linux binary
        run: cargo build --features odbc-static
      - name: Upload Linux Rust test binaries
        uses: actions/upload-artifact@v7
        with:
          name: sqlpage-linux-test-binaries
          path: "target/sqlpage-linux-test-binaries.tar.gz"
      - name: Upload Linux binary
        uses: actions/upload-artifact@v7
        with:
          name: sqlpage-linux-debug
          path: "target/debug/sqlpage"

  test:
    runs-on: ubuntu-latest
    needs: compile_and_lint
    strategy:
      matrix:
        include:
          - database: sqlite
            container: ""
            db_url: "sqlite::memory:"
          - database: postgres
            container: postgres
            db_url: "postgres://root:Password123!@127.0.0.1/sqlpage"
          - database: mysql
            container: mysql
            db_url: "mysql://root:Password123!@127.0.0.1/sqlpage"
          - database: mssql
            container: mssql
            db_url: "mssql://root:Password123!@127.0.0.1/sqlpage"
          - database: odbc
            container: postgres
            db_url: "Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!"
            setup_odbc: true
          - database: oracle
            container: oracle
            db_url: "Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!"
    steps:
      - uses: actions/checkout@v6
      # Reuse the exact Linux test harnesses produced by compile_and_lint.
      # This keeps the DB matrix focused on database behavior instead of
      # compiling the same Rust crate five more times.
      - name: Download Linux Rust test binaries
        uses: actions/download-artifact@v8
        with:
          name: sqlpage-linux-test-binaries
          path: target
      - name: Extract Linux Rust test binaries
        run: |
          mkdir -p target/sqlpage-test-binaries
          tar -xzf target/sqlpage-linux-test-binaries.tar.gz -C target/sqlpage-test-binaries
      - name: Install PostgreSQL ODBC driver
        if: matrix.setup_odbc
        run: sudo apt-get install -y odbc-postgresql
      - name: Install Oracle ODBC driver
        if: matrix.database == 'oracle'
        run: |
          sudo apt-get install -y alien libaio1t64 libodbcinst2 unixodbc
          sudo rpm --import https://yum.oracle.com/RPM-GPG-KEY-oracle-ol8
          wget https://yum.oracle.com/repo/OracleLinux/OL8/oracle/instantclient21/x86_64/getPackage/oracle-instantclient-{basic,odbc}-21.21.0.0.0-1.el8.x86_64.rpm
          sudo alien -i oracle-instantclient-basic-21.21.0.0.0-1.el8.x86_64.rpm
          sudo alien -i oracle-instantclient-odbc-21.21.0.0.0-1.el8.x86_64.rpm
          sudo ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/libaio.so.1
          sudo /usr/lib/oracle/21/client64/bin/odbc_update_ini.sh / /usr/lib/oracle/21/client64/lib
          echo "LD_LIBRARY_PATH=/usr/lib/oracle/21/client64/lib:$LD_LIBRARY_PATH" >> "$GITHUB_ENV"
      - name: Start database container
        if: matrix.container != ''
        run: docker compose up --wait ${{ matrix.container }}
      - name: Show container logs
        if: failure() && matrix.container != ''
        run: docker compose logs ${{ matrix.container }}
      - name: Run tests against ${{ matrix.database }}
        timeout-minutes: 5
        run: |
          set -euo pipefail
          shopt -s nullglob
          test_binaries=(target/sqlpage-test-binaries/*)
          if ((${#test_binaries[@]} == 0)); then
            echo "No test binaries were found in target/sqlpage-test-binaries" >&2
            exit 1
          fi
          for test_binary in "${test_binaries[@]}"; do
            echo "::group::$(basename "$test_binary")"
            "$test_binary"
            echo "::endgroup::"
          done
        env:
          DATABASE_URL: ${{ matrix.db_url }}
          MALLOC_CHECK_: 3
          MALLOC_PERTURB_: 10

  windows_test:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v6
      - name: Set up cargo cache
        uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4
        env:
          NODE_OPTIONS: --no-deprecation
      - name: Set up Windows incremental cache
        uses: actions/cache@v5
        with:
          path: |
            target/debug/.fingerprint/sqlpage-*
            target/debug/build/sqlpage-*
            target/debug/deps/libsqlpage-*.rlib
            target/debug/deps/libsqlpage-*.rmeta
            target/debug/deps/sqlpage-*.d
            target/debug/incremental/sqlpage-*
          key: windows-incremental-${{ github.event.pull_request.number || github.ref_name }}-${{ github.sha }}
          restore-keys: |
            windows-incremental-${{ github.event.pull_request.number || github.ref_name }}-
      - run: cargo test
        env:
          CARGO_INCREMENTAL: 1
          RUST_BACKTRACE: 1
      - name: Upload Windows binary
        uses: actions/upload-artifact@v7
        with:
          name: sqlpage-windows-debug
          path: "target/debug/sqlpage.exe"

  playwright:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    needs: compile_and_lint
    defaults:
      run:
        working-directory: ./tests/end-to-end
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: lts/*
          cache: 'npm'
          cache-dependency-path: ./tests/end-to-end/package-lock.json
      - run: sudo apt-get update && sudo apt-get install -y unixodbc-dev
      - run: npm ci && npx playwright install --with-deps chromium
      # The browser tests exercise the official site, but they do not need a
      # separate Rust build. Reuse the binary compiled and tested above.
      - name: Download Linux binary
        uses: actions/download-artifact@v8
        with:
          name: sqlpage-linux-debug
          path: ${{ runner.temp }}/sqlpage-bin
      - name: Start official site and wait for it to be ready
        timeout-minutes: 1
        run: |
          chmod +x "${{ runner.temp }}/sqlpage-bin/sqlpage"
          "${{ runner.temp }}/sqlpage-bin/sqlpage" 2>/tmp/stderrlog &
          tail -f /tmp/stderrlog | grep -q "started successfully"
        working-directory: ./examples/official-site
      - name: Run Playwright tests
        run: npx playwright test
      - name: show server logs
        if: failure()
        run: cat /tmp/stderrlog
      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: playwright-report
          path: ./tests/end-to-end/playwright-report/
          retention-days: 30

  docker_build:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: linux/arm/v7
            variant: minimal
            tag_suffix: -linux-arm-v7
          - platform: linux/arm64
            variant: minimal
            tag_suffix: -linux-arm64
          - platform: linux/amd64
            variant: duckdb
            tag_suffix: -linux-amd64-duckdb
          - platform: linux/arm64
            variant: duckdb
            tag_suffix: -linux-arm64-duckdb
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - id: cache-scope
        name: Docker cache scope
        run: |
          ref_scope="main"
          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
            ref_scope="pr-${{ github.event.pull_request.number }}"
          fi

          recipe_hash="${{ hashFiles('Dockerfile', '.cargo/**', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'scripts/**', 'sqlpage/**') }}"
          {
            echo "current=sqlpage-${ref_scope}${{ matrix.tag_suffix }}-${recipe_hash}"
            echo "main=sqlpage-main${{ matrix.tag_suffix }}-${recipe_hash}"
          } >> "$GITHUB_OUTPUT"
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.REGISTRY_IMAGE }}
          flavor: suffix=${{ matrix.tag_suffix }}
          labels: |
            org.opencontainers.image.created=1970-01-01T00:00:00Z
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v4
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4
      - name: Login to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v4
        with:
          username: ${{ env.REGISTRY_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Build and push by digest
        id: build
        uses: docker/build-push-action@v7
        with:
          # Use BuildKit's Git context instead of the mutable runner workspace.
          # The dependency cache should be keyed by committed source, not by a
          # per-job local context stream.
          context: "{{defaultContext}}"
          platforms: ${{ matrix.platform }}
          target: ${{ matrix.variant }}
          labels: ${{ steps.meta.outputs.labels }}
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: |
            type=gha,scope=${{ steps.cache-scope.outputs.current }}
            type=gha,scope=${{ steps.cache-scope.outputs.main }}
            type=registry,ref=${{ env.REGISTRY_IMAGE }}:main${{ matrix.tag_suffix }}
          cache-to: type=gha,scope=${{ steps.cache-scope.outputs.current }},mode=max
      - name: Export digest
        if: github.event_name != 'pull_request'
        run: |
          mkdir -p /tmp/digests
          digest="${{ steps.build.outputs.digest }}"
          touch "/tmp/digests/${digest#sha256:}"
      - name: Upload digest
        uses: actions/upload-artifact@v7
        if: github.event_name != 'pull_request'
        with:
          name: digests-${{ matrix.variant }}${{ matrix.tag_suffix }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1

  docker_build_amd64_minimal:
    name: docker_build (linux/amd64, minimal)
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v6
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.REGISTRY_IMAGE }}
          flavor: suffix=-linux-amd64
          labels: |
            org.opencontainers.image.created=1970-01-01T00:00:00Z
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4
      - id: cache-scope
        name: Docker cache scope
        run: |
          ref_scope="main"
          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
            ref_scope="pr-${{ github.event.pull_request.number }}"
          fi

          recipe_hash="${{ hashFiles('Dockerfile', '.cargo/**', 'Cargo.toml', 'Cargo.lock', 'build.rs', 'scripts/**', 'sqlpage/**') }}"
          {
            echo "artifact=sqlpage-${ref_scope}-linux-amd64-hurl-${recipe_hash}"
            echo "current=sqlpage-${ref_scope}-linux-amd64-${recipe_hash}"
            echo "main=sqlpage-main-linux-amd64-${recipe_hash}"
          } >> "$GITHUB_OUTPUT"
      - name: Login to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v4
        with:
          username: ${{ env.REGISTRY_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Build image for Hurl examples
        uses: docker/build-push-action@v7
        with:
          # Use BuildKit's Git context instead of the mutable runner workspace.
          # The dependency cache should be keyed by committed source, not by a
          # per-job local context stream.
          context: "{{defaultContext}}"
          platforms: linux/amd64
          target: minimal
          labels: ${{ steps.meta.outputs.labels }}
          tags: |
            ${{ steps.meta.outputs.tags }}
            ${{ env.REGISTRY_IMAGE }}:main
          outputs: type=docker,dest=${{ runner.temp }}/sqlpage.tar
          cache-from: |
            type=gha,scope=${{ steps.cache-scope.outputs.artifact }}
            type=gha,scope=${{ steps.cache-scope.outputs.current }}
            type=gha,scope=${{ steps.cache-scope.outputs.main }}
            type=registry,ref=${{ env.REGISTRY_IMAGE }}:main-linux-amd64
          cache-to: type=gha,scope=${{ steps.cache-scope.outputs.artifact }},mode=max
      - name: Upload SQLPage image
        uses: actions/upload-artifact@v7
        with:
          name: sqlpage-linux-amd64-minimal-image
          path: ${{ runner.temp }}/sqlpage.tar
          if-no-files-found: error
          retention-days: 1
      - name: Build and push by digest
        id: build
        if: github.event_name != 'pull_request'
        uses: docker/build-push-action@v7
        with:
          # Use BuildKit's Git context instead of the mutable runner workspace.
          # The dependency cache should be keyed by committed source, not by a
          # per-job local context stream.
          context: "{{defaultContext}}"
          platforms: linux/amd64
          target: minimal
          labels: ${{ steps.meta.outputs.labels }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: |
            type=gha,scope=${{ steps.cache-scope.outputs.current }}
            type=gha,scope=${{ steps.cache-scope.outputs.main }}
            type=registry,ref=${{ env.REGISTRY_IMAGE }}:main-linux-amd64
          cache-to: type=gha,scope=${{ steps.cache-scope.outputs.current }},mode=max
      - name: Export digest
        if: github.event_name != 'pull_request'
        run: |
          mkdir -p /tmp/digests
          digest="${{ steps.build.outputs.digest }}"
          touch "/tmp/digests/${digest#sha256:}"
      - name: Upload digest
        uses: actions/upload-artifact@v7
        if: github.event_name != 'pull_request'
        with:
          name: digests-minimal-linux-amd64
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1

  hurl_examples:
    runs-on: ubuntu-latest
    outputs:
      examples: ${{ steps.examples.outputs.examples }}
    steps:
      - uses: actions/checkout@v6
      - id: examples
        run: |
          examples="$(find examples -mindepth 2 -maxdepth 2 -name test.hurl -print | sed 's#/test.hurl$##' | sort | jq -R -s -c 'split("\n")[:-1]')"
          echo "examples=$examples" >> "$GITHUB_OUTPUT"
      - name: Restore Hurl archive
        id: hurl-cache
        uses: actions/cache@v5
        with:
          path: .cache/hurl
          key: hurl-${{ runner.os }}-x86_64-unknown-linux-gnu-${{ env.HURL_VERSION }}
      - name: Download Hurl archive
        if: steps.hurl-cache.outputs.cache-hit != 'true'
        run: |
          set -euo pipefail
          archive="hurl-${HURL_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
          base_url="https://github.com/Orange-OpenSource/hurl/releases/download/${HURL_VERSION}"
          mkdir -p .cache/hurl
          curl --fail --location --retry 5 --retry-delay 2 --output ".cache/hurl/${archive}" "${base_url}/${archive}"
          curl --fail --location --retry 5 --retry-delay 2 --output ".cache/hurl/${archive}.sha256" "${base_url}/${archive}.sha256"
          (
            cd .cache/hurl
            expected_sha="$(tr -d '[:space:]' < "${archive}.sha256")"
            actual_sha="$(sha256sum "$archive" | cut -d ' ' -f 1)"
            test "$expected_sha" = "$actual_sha"
          )
      - name: Upload Hurl archive
        uses: actions/upload-artifact@v7
        with:
          name: hurl-${{ env.HURL_VERSION }}-x86_64-unknown-linux-gnu
          path: .cache/hurl/*
          if-no-files-found: error
          retention-days: 1

  hurl:
    name: hurl (${{ matrix.example }})
    runs-on: ubuntu-latest
    timeout-minutes: 15
    needs:
      - docker_build_amd64_minimal
      - hurl_examples
    strategy:
      fail-fast: false
      matrix:
        example: ${{ fromJSON(needs.hurl_examples.outputs.examples) }}
    steps:
      - uses: actions/checkout@v6
      - name: Download Hurl archive
        uses: actions/download-artifact@v8
        with:
          name: hurl-${{ env.HURL_VERSION }}-x86_64-unknown-linux-gnu
          path: ${{ runner.temp }}/hurl
      - name: Install Hurl
        run: |
          set -euo pipefail
          archive="hurl-${HURL_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
          (
            cd "$RUNNER_TEMP/hurl"
            expected_sha="$(tr -d '[:space:]' < "${archive}.sha256")"
            actual_sha="$(sha256sum "$archive" | cut -d ' ' -f 1)"
            test "$expected_sha" = "$actual_sha"
            tar xzf "$archive" -C "$RUNNER_TEMP/hurl"
          )
          echo "$RUNNER_TEMP/hurl/hurl-${HURL_VERSION}-x86_64-unknown-linux-gnu/bin" >> "$GITHUB_PATH"
      - name: Download SQLPage image
        uses: actions/download-artifact@v8
        with:
          name: sqlpage-linux-amd64-minimal-image
          path: ${{ runner.temp }}/sqlpage-image
      - name: Load SQLPage image
        run: |
          docker load --input "${{ runner.temp }}/sqlpage-image/sqlpage.tar"
          docker tag "${{ env.REGISTRY_IMAGE }}:main" "${{ env.REGISTRY_IMAGE }}:latest"
      - name: Run example Hurl test
        run: scripts/test-examples-hurl.sh "${{ matrix.example }}"

  docker_push:
    runs-on: ubuntu-latest
    if: github.event_name != 'pull_request'
    needs:
      - docker_build_amd64_minimal
      - docker_build
    strategy:
      matrix:
        variant:
          - minimal
          - duckdb
    steps:
      - name: Download digests
        uses: actions/download-artifact@v8
        env:
          NODE_OPTIONS: --no-deprecation
        with:
          pattern: digests-${{ matrix.variant }}*
          merge-multiple: true
          path: /tmp/digests
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4
      - name: Login to Docker Hub
        uses: docker/login-action@v4
        with:
          username: ${{ env.REGISTRY_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Create manifest list and push
        working-directory: /tmp/digests
        env:
          TAG_SUFFIX: ${{ matrix.variant != 'minimal' && format('-{0}', matrix.variant) || '' }}
        run: |
          tags=(-t "${REGISTRY_IMAGE}:${GITHUB_REF_NAME}${TAG_SUFFIX}")
          if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
            tags+=(-t "${REGISTRY_IMAGE}:latest${TAG_SUFFIX}")
          fi
          shopt -s nullglob
          sources=()
          for digest in *; do
            sources+=("${REGISTRY_IMAGE}@sha256:${digest}")
          done
          ((${#sources[@]} > 0))
          docker buildx imagetools create "${tags[@]}" "${sources[@]}"
      - name: Inspect image
        env:
          TAG_SUFFIX: ${{ matrix.variant != 'minimal' && format('-{0}', matrix.variant) || '' }}
        run: |
          docker buildx imagetools inspect "${REGISTRY_IMAGE}:${GITHUB_REF_NAME}${TAG_SUFFIX}"
