name: CI

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
  # Allow a manual run from the Actions tab. Needed because the GitHub
  # repo is a push-mirror of Codeberg: mirror syncs update refs via the
  # API and do NOT fire `push` events, so on:push never triggers here.
  workflow_dispatch:

jobs:
  build-and-test:
    name: build PG${{ matrix.pg }} / ${{ matrix.cc }} / ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-24.04, macos-14]
        pg: ['17', '18', '19']
        cc: [gcc, clang]
        exclude:
          - os: macos-14
            cc: gcc
          # Homebrew has no postgresql@19 yet.
          - os: macos-14
            pg: '19'
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: true

      - name: Install build deps (Ubuntu)
        if: runner.os == 'Linux'
        run: |
          # PG 17/18 are not in the default Ubuntu repos; add PGDG
          # (apt.postgresql.org) before installing the versioned packages.
          sudo apt-get update
          sudo apt-get install -y wget ca-certificates gnupg lsb-release
          wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc \
              | sudo gpg --dearmor -o /usr/share/keyrings/pgdg.gpg
          echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
              | sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null
          # PG 19 is a release candidate: PGDG ships it in the "testing"
          # suite until 19.0, with the libpq it needs (main's 18.x libpq
          # sorts higher than 19~rc1, so pin the testing suite for it).
          # Drop this once 19 moves to -pgdg main.
          APT_TARGET=
          if [ "${{ matrix.pg }}" = 19 ]; then
              echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg-testing main 19" \
                  | sudo tee /etc/apt/sources.list.d/pgdg-testing.list >/dev/null
              APT_TARGET="-t $(lsb_release -cs)-pgdg-testing"
          fi
          sudo apt-get update
          sudo apt-get install -y \
              autoconf automake libtool gettext autopoint m4 build-essential clang
          sudo apt-get install -y $APT_TARGET \
              postgresql-server-dev-${{ matrix.pg }} postgresql-${{ matrix.pg }}

      - name: Install build deps (macOS)
        if: runner.os == 'macOS'
        run: |
          brew install autoconf automake libtool gettext m4 postgresql@${{ matrix.pg }}
          echo "/opt/homebrew/opt/postgresql@${{ matrix.pg }}/bin" >> "$GITHUB_PATH"

      - name: Build
        env:
          CC: ${{ matrix.cc }}
        # -Werror via PG_CFLAGS, not CFLAGS, because pgxs overrides CFLAGS.
        # We're already at zero warnings; this enforcement keeps it that way.
        # The same -Wno-* knobs are needed because PG headers and pgxs's
        # default flags trip GCC false positives that we don't want to fix.
        run: |
          PG_CFLAGS="-Werror -Wno-error=unused-parameter"
          # pgxs injects -fexcess-precision=standard (a GCC flag) into
          # CFLAGS; Apple/LLVM clang on macOS doesn't accept it and emits
          # -Wignored-optimization-argument, which -Werror would promote to
          # a hard error.  Demote just that one warning on macOS.
          if [ "$RUNNER_OS" = "macOS" ]; then
              # pgxs injects -fexcess-precision=standard (a GCC optimization
              # flag) and PG17's -Wno-cast-function-type-strict (a GCC warning
              # flag) into CFLAGS. Apple/LLVM clang doesn't recognize either:
              # the former emits -Wignored-optimization-argument, the latter
              # -Wunknown-warning-option. -Werror would promote both to hard
              # errors, so demote just those two on macOS.
              PG_CFLAGS="$PG_CFLAGS -Wno-error=ignored-optimization-argument -Wno-error=unknown-warning-option"
          fi
          PG_CONFIG=$(which pg_config) make PG_CFLAGS="$PG_CFLAGS"

      - name: Install
        run: sudo PG_CONFIG=$(which pg_config) make install

      - name: Start PostgreSQL (Linux)
        if: runner.os == 'Linux'
        run: |
          # The runner image ships a default cluster (often a different
          # PG major) that already binds port 5432, so pg_createcluster
          # places our new cluster on 5433 and the client tools, which
          # default to 5432, can't reach it. Drop every existing cluster,
          # then create ours pinned to 5432.
          while read -r ver clu _; do
              [ -n "$ver" ] && sudo pg_dropcluster --stop "$ver" "$clu" || true
          done < <(pg_lsclusters -h)
          sudo pg_createcluster ${{ matrix.pg }} main -p 5432 -- --auth-local=trust
          sudo pg_ctlcluster ${{ matrix.pg }} main start
          # Wait for the server to accept connections before createdb.
          for i in $(seq 1 30); do
              pg_isready -p 5432 -q && break
              sleep 1
          done
          sudo -u postgres createdb "$USER"
          # run-regress.sh connects via local trust as the OS user
          # ($USER = runner) with no -U, so that role must exist as a
          # superuser (createdb/dropdb own the throwaway DB).  The
          # default pg_createcluster only has 'postgres'; create the
          # matching role.  (Previously masked: run-regress aborted on
          # /bin/sh pipefail before reaching dropdb, then the
          # '|| make check' fallback swallowed the failure.)
          sudo -u postgres createuser --superuser "$USER"
        env:
          PGPORT: "5432"

      - name: Run regression tests (3x for flake detection)
        # Linux only.  The regression suite now runs on BOTH PG17 and
        # PG18: the version-sensitive tests were made version-robust
        # (build-progress NOTICEs suppressed; exact EXPLAIN plan-shape
        # assertions replaced with single-token "uses index?" probes via
        # EXPLAIN FORMAT JSON), so the expected files no longer depend
        # on PG-major-specific trigram/posting counts or planner cost
        # choices.  macOS legs validate build portability only (no
        # server is started there).
        if: runner.os == 'Linux'
        env:
          PGPORT: "5432"
        run: |
          set -e
          for i in 1 2 3; do
              echo "==> Regression run $i/3"
              # localcheck is the supported driver (run-regress.sh).  Do
              # NOT fall back to 'make check' on failure: that target is
              # unsupported here and its non-fatal exit silently masked
              # real localcheck failures (CI went green while regression
              # tests failed).  A localcheck failure must fail the step.
              PG_CONFIG=$(which pg_config) make localcheck
          done

      - name: Regression tests in a C.UTF-8 database
        # The cluster above uses the runner's default locale.  Fuzzy
        # matching used to count bytes under a C ctype and characters
        # under a UTF-8 one, and utf8_fuzzy's expected file silently
        # encoded whichever the author's cluster had.  Run the suite again
        # in an explicitly C.UTF-8 database so both are always covered;
        # the expected files are identical for the two.
        if: runner.os == 'Linux'
        env:
          PGPORT: "5432"
        run: |
          CREATEDB_OPTS="-T template0 -E UTF8 --locale=C.UTF-8" \
              PG_CONFIG=$(which pg_config) bash scripts/run-regress.sh

      - name: Upload regression artifacts on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: regression-${{ matrix.os }}-${{ matrix.cc }}-pg${{ matrix.pg }}
          path: test/results/
          retention-days: 14

  nix-clang-tap:
    # The shipped artefact is the flake build (clang -O2 + LTO).  An ERROR
    # raised inside a pending-list scan crashed only that build: 4.2.0-4.2.2
    # read clobbered setjmp locals in PG_CATCH, and only clang turned that
    # into "free(): invalid pointer" (gcc and the plain clang -O2 PGXS build
    # keep the locals in memory, so build-and-test above never saw it).
    # Run the gating TAP test against exactly what `nix build .#pg18` ships.
    name: nix .#pg18 (clang LTO) / scan_cancel TAP
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: DeterminateSystems/nix-installer-action@main
      - name: Install TAP deps
        run: sudo apt-get update && sudo apt-get install -y libipc-run-perl
      - name: scan_cancel.pl against the flake build
        run: |
          set -euo pipefail
          EXT=$(nix build --no-link --print-out-paths .#pg18)
          PG=$(nix build --no-link --print-out-paths .#postgresql.out)
          PGC=$(nix build --no-link --print-out-paths .#postgresql.pg_config)
          PGDEV=$(nix build --no-link --print-out-paths .#postgresql.dev)
          PGSRC=$(nix eval --raw .#postgresql.src)
          nix build --no-link "$PGSRC"
          T=$(mktemp -d); mkdir -p "$T/src/test"
          cp -r "$PGSRC/src/test/perl" "$T/src/test/"
          # Load the flake's extension into the flake's PostgreSQL without
          # copying the store: PG18 searches these paths.
          cat > "$T/tre.conf" <<EOF
          shared_preload_libraries = '$EXT/lib/pg_tre'
          dynamic_library_path = '$EXT/lib:\$libdir'
          extension_control_path = '$EXT/share/postgresql:\$system'
          EOF
          TEMP_CONFIG="$T/tre.conf" PATH="$PG/bin:$PGC/bin:$PATH" \
            PERL5LIB="$T/src/test/perl" \
            PG_REGRESS="$PGDEV/lib/pgxs/src/test/regress/pg_regress" \
            TMPDIR=/tmp prove -v tap/scan_cancel.pl

  lint-docs:
    name: lint & validate
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: false

      - name: Install lint deps
        run: |
          sudo apt-get update
          sudo apt-get install -y nodejs npm
          sudo npm install -g markdownlint-cli

      - name: Lint Markdown
        continue-on-error: true
        run: |
          if [ -f .markdownlint.json ]; then
              markdownlint '**/*.md' \
                  --ignore vendor --ignore build --ignore bench/corpus.csv
          else
              echo "No .markdownlint.json; skipping"
          fi

      - name: Verify no committed binary artifacts
        run: |
          if git ls-files | grep -E '\.(o|so|dylib|a)$' ; then
              echo "FAIL: committed binary artifacts"
              exit 1
          fi

      - name: Verify flake-pinned submodule revs match .gitmodules gitlinks
        run: |
          # The flake fetches vendor/tre and vendor/lime as pinned inputs
          # (so `nix build` works without ?submodules=1).  Those revs must
          # stay in lockstep with the actual submodule gitlinks, or the
          # `nix build` output would diverge from the `make` build.
          fail=0
          check() { # $1=submodule path  $2=flake url substring
            gl=$(git ls-tree HEAD "$1" | awk '{print $3}')
            if ! grep -qF "$gl" flake.nix; then
              echo "DRIFT: $1 gitlink $gl not pinned in flake.nix"
              fail=1
            fi
          }
          check vendor/tre
          check vendor/lime
          [ "$fail" = 0 ] && echo "flake inputs match submodule gitlinks"
          exit $fail

      - name: Verify STATUS.md is populated
        run: |
          test -s STATUS.md && grep -qE "Released:|## What ships" STATUS.md

      - name: Check all README doc links resolve
        run: |
          # A soft lint: fail the job only if README references
          # a local path that doesn't exist.
          grep -oE '\]\(([^)]+)\)' README.md \
              | sed -e 's/^](\(.*\))$/\1/' \
              | while read ref; do
                  case "$ref" in
                      http*|mailto:*|\#*) continue ;;
                  esac
                  if [ ! -e "$ref" ]; then
                      echo "MISSING: $ref (referenced in README.md)"
                      exit 1
                  fi
              done

  coverage:
    name: coverage upload (informational)
    runs-on: ubuntu-24.04
    # Pushes only — PR coverage runs as part of build-and-test.
    if: github.event_name == 'push'
    continue-on-error: true
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      - name: Install build deps + lcov
        run: |
          # PG 18 is not in the default Ubuntu repos; add PGDG first.
          sudo apt-get update
          sudo apt-get install -y wget ca-certificates gnupg lsb-release
          wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc \
              | sudo gpg --dearmor -o /usr/share/keyrings/pgdg.gpg
          echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
              | sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null
          sudo apt-get update
          sudo apt-get install -y \
            autoconf automake libtool gettext autopoint m4 \
            build-essential lcov \
            postgresql-server-dev-18 postgresql-18

      - name: Build with coverage
        run: |
          export PATH=/usr/lib/postgresql/18/bin:$PATH
          # coverage-build runs `make install` without sudo, so make the
          # PG install dirs writable by the runner user first.
          for d in "$(pg_config --pkglibdir)" "$(pg_config --sharedir)/extension" \
                   "$(pg_config --pkglibdir)/bitcode"; do
              sudo mkdir -p "$d"
              sudo chown -R "$(id -u):$(id -g)" "$d"
          done
          PG_CONFIG=$(which pg_config) make coverage-build

      - name: Start PostgreSQL
        env:
          PGPORT: "5432"
          PGDATA: ${{ github.workspace }}/.pgdata
        run: |
          # Stop any pre-existing cluster so it can't hold port 5432.
          while read -r ver clu _; do
              [ -n "$ver" ] && sudo pg_dropcluster --stop "$ver" "$clu" || true
          done < <(pg_lsclusters -h)
          export PATH=/usr/lib/postgresql/18/bin:$PATH
          # Run the postmaster AS THE RUNNER USER (not postgres) from a
          # runner-owned datadir.  GCC --coverage writes .gcda files to the
          # absolute build-tree path baked into the objects; only the build
          # tree's owner can create them, so the backend must run as that
          # same user.  (systemd/pg_ctlcluster run as postgres and also
          # sandbox /home, which blocked both .so load and .gcda writes.)
          initdb -D "$PGDATA" --auth-local=trust --username="$USER"
          # Disable JIT: the -O0 coverage build inflates index-scan cost
          # estimates past the JIT threshold, adding a JIT: block to
          # planner_auto's EXPLAIN that the optimized expected files lack.
          {
            echo "port = 5432"
            echo "jit = off"
            echo "unix_socket_directories = '/tmp'"
          } >> "$PGDATA/postgresql.conf"
          pg_ctl -D "$PGDATA" -l "$PGDATA/postmaster.log" -w start
          for i in $(seq 1 30); do
              pg_isready -p 5432 -h /tmp -q && break
              sleep 1
          done
          # run-regress.sh connects as the OS user with no -U; ensure the
          # default DB exists for that login.
          createdb -h /tmp -p 5432 "$USER" || true

      - name: Run tests under coverage
        env:
          PGPORT: "5432"
          PGHOST: "/tmp"
        run: |
          export PATH=/usr/lib/postgresql/18/bin:$PATH
          PG_CONFIG=$(which pg_config) make coverage

      - name: Upload to Codecov
        # Configuration is in .codecov.yml; the action picks up
        # coverage.info automatically.  Codecov v4 rejects tokenless
        # uploads from this repo, so the CODECOV_TOKEN secret (set on
        # the GitHub repo) is required.
        uses: codecov/codecov-action@v4
        with:
          files: coverage.info
          flags: regression
          fail_ci_if_error: false
          token: ${{ secrets.CODECOV_TOKEN }}

  publish-docs:
    name: publish docs to GitHub Pages
    runs-on: ubuntu-24.04
    if: >-
      (github.event_name == 'push' && github.ref == 'refs/heads/main')
      || github.event_name == 'workflow_dispatch'
    needs: [lint-docs]
    permissions:
      contents: read
      pages: write
      id-token: write
    # actions/deploy-pages@v4 requires the deployment to target a
    # `github-pages` environment; the URL is surfaced on the run.
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    # Serialize Pages deployments so two pushes to main can't race the
    # deploy API (which rejects concurrent in-progress deployments).
    concurrency:
      group: github-pages
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@v4

      - name: Install mdBook
        uses: peaceiris/actions-mdbook@v2
        with:
          mdbook-version: '0.4.52'

      - name: Build site with mdBook
        run: mdbook build

      - name: Upload pages artifact
        uses: actions/upload-pages-artifact@v3
        with:
          path: doc-site/

      - name: Deploy to Pages
        id: deployment
        uses: actions/deploy-pages@v4

  release-artifacts:
    name: build release tarball
    runs-on: ubuntu-24.04
    if: startsWith(github.ref, 'refs/tags/v')
    needs: [build-and-test, lint-docs]
    # Allow the GITHUB_TOKEN to create a GitHub Release.  The default
    # token is read-only; without this the publish step 403s with
    # "Resource not accessible by integration".
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: true

      - name: Install dist deps
        run: sudo apt-get update && sudo apt-get install -y make tar gzip

      - name: Build distribution tarball
        run: make dist

      - name: Create release
        # Best-effort: this repository's canonical releases are the
        # git tags on Codeberg; the GitHub repo is a mirror.  The
        # tarball *build* above is the packaging gate; publishing a
        # GitHub Release is a convenience and must not red the
        # pipeline if the mirror's token policy forbids it.
        continue-on-error: true
        uses: softprops/action-gh-release@v2
        with:
          files: |
            pg_tre-*.tar.gz
          draft: false
          prerelease: ${{ contains(github.ref, 'rc') }}
          generate_release_notes: true
