name: CI

env:
  PROJECT_NAME: unison
  PROJECT_DESC: "`unison` file synchronizer"
  PROJECT_EXES: "unison unison-fsmonitor"

on:
  - pull_request
  - push

jobs:
  docs:
    runs-on: ubuntu-20.04

    steps:
    - run: sudo apt-get update

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

    - name: Use OCaml
      uses: ocaml/setup-ocaml@v2
      with:
        ocaml-compiler: 4.14.x
        opam-depext: false

    - run: sudo apt-get install hevea lynx texlive-latex-base

    - run: opam exec -- make docs

    - name: Store user manual for the build jobs
      uses: actions/upload-artifact@v4
      with:
        name: unison-docs
        path: |
          doc/unison-manual.txt
          doc/unison-manual.html
          doc/unison-manual.pdf
          man/unison.1

  build:
    if: ${{ !cancelled() }}    # Don't fail if 'docs' failed
    needs: docs

    strategy:
      fail-fast: false
      matrix:
        job:
        - { os: macos-14      , ocaml-version: 5.2.0 }
        - { os: macos-14      , ocaml-version: 4.14.2           , publish: true  , fnsuffix: -macos-arm64 }
        - { os: macos-12      , ocaml-version: 4.14.2           , publish: true  , fnsuffix: -macos-x86_64 }
        - { os: ubuntu-22.04  , ocaml-version: 5.2.0 }
        - { os: ubuntu-22.04  , ocaml-version: 4.14.2 }
        - { os: ubuntu-20.04  , ocaml-version: 4.14.2 }
        - { os: windows-2022  , ocaml-version: 4.14.0+mingw64c  , publish: true  , fnsuffix: -windows-x86_64 }
        - { os: windows-2019  , ocaml-version: 4.14.0+mingw32c  , publish: true  , fnsuffix: -windows-i386 }

    runs-on: ${{ matrix.job.os }}

    steps:
    - if: contains(matrix.job.os, 'ubuntu')
      run: sudo apt-get update

    - if: runner.os == 'Windows'
      name: "Windows: Stash away the default MSYS installation"
      continue-on-error: true
      shell: cmd
      # This conflicts with Cygwin installed by setup-ocaml
      # Adjusting PATH alone does not seem to work
      run: rename C:\msys64 dmsys64

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

    - name: Initialize workflow variables
      id: vars
      shell: bash
      run: |
        outputs() { for var in "$@" ; do echo steps.vars.outputs.${var}="${!var}"; echo "${var}=${!var}" >> $GITHUB_OUTPUT ; done; }
        # normalize to pre-compiled ocaml compiler variants for windows/Cygwin (decreases OCaml install time by 50%)
        case '${{ matrix.job.ocaml-version }}' in
          *+*) OCAML_COMPILER='ocaml-variants.${{ matrix.job.ocaml-version }}' ;;
            *) OCAML_COMPILER='ocaml-base-compiler.${{ matrix.job.ocaml-version }}' ;;
        esac
        OCAML_VARIANT='${{ matrix.job.ocaml-version }}'
        OCAML_VARIANT="${OCAML_VARIANT/+options/}"
        outputs OCAML_VARIANT OCAML_COMPILER
        # architecture/platform vars
        EXE_suffix='' ; case '${{ matrix.job.os }}' in windows-*) EXE_suffix=".exe" ;; esac
        MinGW_ARCH='x86_64' ; case '${{ matrix.job.ocaml-version }}' in *+mingw32*) MinGW_ARCH='i686' ;; *+mingw64*) MinGW_ARCH='x86_64' ;; esac
        MSVC_ARCH='' ; case '${{ matrix.job.ocaml-version }}' in *+msvc32*) MSVC_ARCH='x86' ;; *+msvc64*) MSVC_ARCH='x64' ;; esac
        outputs EXE_suffix MinGW_ARCH MSVC_ARCH
        case '${{ matrix.job.os }}','${{ matrix.job.ocaml-version }}' in
          macos-*,4*) MACOSX_DEPLOYMENT_TARGET=10.6 ;; macos-*,5*) MACOSX_DEPLOYMENT_TARGET=10.7 ;;
          macos-14*,*) MACOSX_DEPLOYMENT_TARGET=10.13 ;;
        esac
        case '${{ matrix.job.os }}' in
          macos-*) echo "MACOSX_DEPLOYMENT_TARGET=${MACOSX_DEPLOYMENT_TARGET}" >> $GITHUB_ENV ;
                   echo "XCODEFLAGS=-arch $(uname -m) -mmacosx-version-min=${MACOSX_DEPLOYMENT_TARGET} MACOSX_DEPLOYMENT_TARGET=${MACOSX_DEPLOYMENT_TARGET}" >> $GITHUB_ENV ;
                   echo "CFLAGS=${CFLAGS} -mmacosx-version-min=${MACOSX_DEPLOYMENT_TARGET}" >> $GITHUB_ENV ;;
        esac
        # staging environment
        STAGING_DIR='_staging'
        outputs STAGING_DIR
        # parse commit reference info
        echo GITHUB_REF=${GITHUB_REF}
        echo GITHUB_SHA=${GITHUB_SHA}
        REF_NAME="${GITHUB_REF#refs/*/}"
        unset REF_BRANCH ; case "${GITHUB_REF}" in refs/heads/*) REF_BRANCH="${GITHUB_REF#refs/heads/}" ;; esac;
        unset REF_TAG ; case "${GITHUB_REF}" in refs/tags/*) REF_TAG="${GITHUB_REF#refs/tags/}" ;; esac;
        REF_SHAS="${GITHUB_SHA:0:8}"
        outputs REF_BRANCH REF_NAME REF_SHAS REF_TAG
        # deployable tag? (ie, leading "vM" or "M"; M == version number)
        unset DEPLOY ; if [[ $REF_TAG =~ ^[vV]?[0-9].* ]]; then DEPLOY='true' ; fi
        outputs DEPLOY
        # package name
        PKG_suffix='.tar.gz' ; case '${{ matrix.job.os }}' in windows-*) PKG_suffix='.zip' ;; esac;
        PKG_VER="${REF_TAG:-git_$REF_SHAS}"
        PKG_VER="${PKG_VER#v}"
        PKG_BASENAME="${PROJECT_NAME}-${PKG_VER}${{ matrix.job.fnsuffix }}"
        PKG_NAME="${PKG_BASENAME}${PKG_suffix}"
        PKG_DIR="${STAGING_DIR}/${PKG_BASENAME}"
        outputs PKG_VER PKG_BASENAME PKG_DIR PKG_NAME PKG_suffix
        COMPRESS_CMD='tar czf'; case '${{ matrix.job.os }}' in windows-*) COMPRESS_CMD='7z -y a' ;; esac;
        outputs COMPRESS_CMD

    - name: Create/configure any needed build/workspace
      shell: bash
      run: |
        # create build/work space
        mkdir -p '${{ steps.vars.outputs.STAGING_DIR }}'
        mkdir -p '${{ steps.vars.outputs.PKG_DIR }}'
        mkdir -p '${{ steps.vars.outputs.PKG_DIR }}'/bin

    - name: Enable/config MSVC environment (if/when needed)
      uses: ilammy/msvc-dev-cmd@v1
      with:
        arch: "${{ steps.vars.outputs.MSVC_ARCH }}"
      if: contains(matrix.job.ocaml-version, '+msvc')

    - name: Use OCaml ${{ matrix.job.ocaml-version }}
      uses: ocaml/setup-ocaml@v2
      with:
        ocaml-compiler: "${{ steps.vars.outputs.OCAML_COMPILER }}"
        opam-pin: false
        opam-depext: false
      # setup-ocaml can prepare the build environment from unison.opam
      # We're not relying on that capability here, to make sure the builds
      # also work without using unison.opam

    ## note: at this point, after OCaml installation, windows platforms will use Cygwin bash as the default
    ##   ... Cygwin bash cannot handle shell scripts containing CRLF EOLs (which are what is generated by GHA on windows platforms)
    ##   ... so, "igncr" must be added to SHELLOPTS

    - name: Prepare Cygwin environment (Windows)
      if: runner.os == 'Windows'
      shell: cmd
      run: |
        echo %CYGWIN_ROOT_BIN%>> %GITHUB_PATH%
        echo %CYGWIN_ROOT_WRAPPERBIN%>> %GITHUB_PATH%
        echo "/usr/${{ steps.vars.outputs.MinGW_ARCH }}-w64-mingw32/sys-root/mingw/bin">> %GITHUB_PATH%
        echo SHELLOPTS=igncr>> %GITHUB_ENV%

    - shell: bash
      env:
        LDFLAGS: ${{ matrix.job.static }}
      run: |
        opam exec -- make tui
        opam exec -- make fsmonitor
        # stage
        # * notes: darwin/macos doesn't build `unison-fsmonitor`
        for file in ${PROJECT_EXES} ; do
          if [ -f "src/${file}${{ steps.vars.outputs.EXE_suffix }}" ]; then
            cp "src/${file}${{ steps.vars.outputs.EXE_suffix }}" '${{ steps.vars.outputs.PKG_DIR }}/bin'
            echo "'src/${file}${{ steps.vars.outputs.EXE_suffix }}' copied to '${{ steps.vars.outputs.PKG_DIR }}/bin'"
          fi
        done

    - run: opam exec -- make test

    ## There is still code to run tests with old ocaml on Windows.
    ## That remains intentionally so that someone could turn it on if
    ## desired.
    - name: Run self-tests over RPC
      if: runner.os == 'Windows' && !contains(matrix.job.ocaml-version, '4.14')
      shell: bash
      run: |
        # Separate backup dir must be set for server instance so that the central
        # backup location of both instances doesn't overlap
        UNISONBACKUPDIR=./src/testbak2 ./src/unison -socket 55443 &
        sleep 1 # Wait for the server to be fully started
        ./src/unison -ui text -selftest testr1 socket://127.0.0.1:55443/testr2 -killserver

    - name: Run self-tests over local socket
      # Recent Windows versions do support Unix domain sockets
      # but at least OCaml 4.14 is required to use that support
      if: runner.os != 'Windows' || contains(matrix.job.ocaml-version, '4.14')
      shell: bash
      run: |
        mkdir localsocket
        chmod 700 localsocket
        # Separate backup dir must be set for server instance so that the central
        # backup location of both instances doesn't overlap
        UNISONBACKUPDIR=./src/testbak4 ./src/unison -socket ./localsocket/test.sock &
        sleep 1 # Wait for the server to be fully started
        ${{ runner.os == 'Windows' }} || test -S ./localsocket/test.sock
        ./src/unison -ui text -selftest testr3 socket://{./localsocket/test.sock}/testr4 -killserver

    - name: Prepare lablgtk install (Windows)
      if: ${{ runner.os == 'Windows' && contains(matrix.job.ocaml-version, '+mingw') }}
      shell: bash
      run: |
        opam install opam-depext depext-cygwinports
        setup-x86_64.exe --quiet-mode --root "${CYGWIN_ROOT}" --site http://cygwin.mirror.constant.com --symlink-type=sys --packages hicolor-icon-theme,adwaita-icon-theme
        # [2022-11] This terrible (terrible) hack is here to forcibly skip
        # building the fontconfig cache because it can take 30-45 minutes
        # on GHA runners and is never needed anyway.
        setup-x86_64.exe --quiet-mode --root "${CYGWIN_ROOT}" --site http://cygwin.mirror.constant.com --symlink-type=sys --local-package-dir D:/a --download --packages mingw64-${{ steps.vars.outputs.MinGW_ARCH }}-fontconfig
        cd 'D:/a/https%3a%2f%2fcygwin.mirror.constant.com%2f/noarch/release/'mingw64-${{ steps.vars.outputs.MinGW_ARCH }}-fontconfig
        CNAMEXZ=$(ls mingw64-${{ steps.vars.outputs.MinGW_ARCH }}-fontconfig*.tar.xz)
        CNAME=${CNAMEXZ%.xz}
        unxz ${CNAMEXZ}
        tar --delete --file ${CNAME} etc/postinstall/zp_mingw64-${{ steps.vars.outputs.MinGW_ARCH }}-fontconfig_cache.sh
        xz ${CNAME}
        sha512sum > sha512.sum
        CSZ=$(stat -c %s ${CNAMEXZ})
        SHASUM=$(sha512sum ${CNAMEXZ})
        cd 'D:/a/https%3a%2f%2fcygwin.mirror.constant.com%2f/x86_64'
        mv setup.ini tsetup.ini
        rm -f setup*
        sed -E -e "\|install: noarch/release/mingw64-${{ steps.vars.outputs.MinGW_ARCH }}-fontconfig/${CNAMEXZ}|s/xz .+/xz ${CSZ} ${SHASUM%% *}/" tsetup.ini > setup.ini
        rm tsetup.ini
        sha512sum > sha512.sum
        setup-x86_64.exe --quiet-mode --root "${CYGWIN_ROOT}" --symlink-type=sys --local-install --local-package-dir 'D:/a/https%3a%2f%2fcygwin.mirror.constant.com%2f' --mirror-mode --no-verify --packages mingw64-${{ steps.vars.outputs.MinGW_ARCH }}-fontconfig

    - name: lablgtk install
      ## [2020-09] non-working/unavailable for MSVC or musl OCaml variants ; also, non-working for 32bit OCaml variant (see [GH:garrigue/lablgtk#64](https://github.com/garrigue/lablgtk/issues/64))
      if: ${{ ! ( contains(matrix.job.ocaml-version, '+msvc') || contains(matrix.job.ocaml-version, '-musl') || contains(matrix.job.ocaml-version, '-32bit') ) }}
      run: opam depext --install --verbose --yes lablgtk3 && opam install ocamlfind

    - if: ${{ !matrix.job.static }} ## unable to build static gtk/gui
      shell: bash
      run: |
        opam exec -- make gui
        # stage
        # * copy only main/first project binary
        project_exe_stem=${PROJECT_EXES%% *}
        cp "src/${project_exe_stem}-gui${{ steps.vars.outputs.EXE_suffix }}" "${{ steps.vars.outputs.PKG_DIR }}/bin/"

    - name: "Build WinOS text+gui hybrid"
      if: ${{ runner.os == 'Windows' && !matrix.job.static }} ## WinOS, non-static (unable to build static gtk/gui)
      shell: bash
      run: |
        # create and stage text+gui hybrid for Windows
        # * copy only main/first project binary
        project_exe_stem=${PROJECT_EXES%% *}
        # * clean/remove build artifact(s)
        rm "src/${project_exe_stem}-gui${{ steps.vars.outputs.EXE_suffix }}" ##.or.# opam exec -- make -C src clean #.or.# opam exec -- make clean
        # * re-create (with hybrid text+gui UI)
        opam exec -- make gui UI_WINOS=hybrid
        cp "src/${project_exe_stem}-gui${{ steps.vars.outputs.EXE_suffix }}" "${{ steps.vars.outputs.PKG_DIR }}/bin/${project_exe_stem}-text+gui${{ steps.vars.outputs.EXE_suffix }}"

    - uses: actions/upload-artifact@v4
      if: false ## disable by default; only useful for debugging GHA
      with:
        name: unison-${{ steps.vars.outputs.REF_SHAS }}.ocaml-${{ matrix.job.ocaml-version }}.${{ matrix.job.os }}
        path: ${{ steps.vars.outputs.PKG_DIR }}/bin/*

    - name: Copy user manual
      continue-on-error: ${{ !(steps.vars.outputs.DEPLOY && matrix.job.publish) }}
      uses: actions/download-artifact@v4
      with:
        name: unison-docs
        path: '${{ steps.vars.outputs.PKG_DIR }}'

    - name: Prepare package
      # if: steps.vars.outputs.DEPLOY
      shell: bash
      run: |
        ## package artifact(s)
        PKG_DIR='${{ steps.vars.outputs.PKG_DIR }}'
        # `strip` binaries
        strip "${PKG_DIR}/bin"/*'${{ steps.vars.outputs.EXE_suffix }}'
        # README and LICENSE
        (shopt -s nullglob; for f in [R]'EADME'{,.*}; do cp $f "${PKG_DIR}"/ ; done)
        (shopt -s nullglob; for f in [L]'ICENSE'{-*,}{,.*}; do cp $f "${PKG_DIR}"/ ; done)
        cp INSTALL.md "${PKG_DIR}"/
        cp CONTRIBUTING.md "${PKG_DIR}"/
        cp NEWS.md "${PKG_DIR}"/

    - if: runner.os == 'Windows'
      name: "Windows: Package gtk"
      shell: bash
      run: |
        # [2023-03] Setting this PATH here (and it has to be right here) is
        # a workaround for an unknown issue (most likely something with GHA)
        # causing MinGW binutils not to be found (while other binutils
        # pre-installed in the GHA images may be found instead), which in turn
        # causes the DLL extracting functions to silently fail.
        export PATH="/usr/${{ steps.vars.outputs.MinGW_ARCH }}-w64-mingw32/bin":${PATH}
        ## package artifact(s)
        PKG_DIR='${{ steps.vars.outputs.PKG_DIR }}'
        # collect any needed dlls/libraries
        # dlls
        dll_refs() { eval "$(opam env)" ; eval "$(ocaml-env cygwin)" ; objdump -x "$@" | grep -Po "\S+[.]dll$" | xargs -I{} 2>/dev/null which "{}" | sort -u ; }
        filtered_dll_refs() { list="$(dll_refs "$@" | grep -vF "$(cygpath ${WINDIR})" | perl -lape '$_ = qq/@{[sort @F]}/')" ; echo "$list" ; }
        recursive_filtered_dll_refs() { list="$(filtered_dll_refs "$@")" ; n=0 ; while [ $n -lt $(echo "$list" | wc -l) ]; do n=$(echo "$list" | wc -l) ; list="$(filtered_dll_refs $list)" ; done ; echo "$list" ; }
        IFS=$'\n' DLL_list=( "$(recursive_filtered_dll_refs "${PKG_DIR}"/bin/*)" )
        for dll in ${DLL_list[@]} ; do cp "${dll}" "${PKG_DIR}"/bin ; done
        TARGET_ARCH_ID='x86_64'; case '${{ matrix.job.ocaml-version }}' in *+mingw32*|*+msvc32*) TARGET_ARCH_ID='i686' ;; esac
        # required gdk support files
        mkdir "${PKG_DIR}"/lib
        cp -r /usr/${TARGET_ARCH_ID}-w64-mingw32/sys-root/mingw/lib/gdk-pixbuf-2.0 "${PKG_DIR}"/lib/
        # update loader.cache to point to local relative installation
        mv "${PKG_DIR}"/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache "${PKG_DIR}"/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache.original
        cat "${PKG_DIR}"/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache.original | sed -E 's#([^"]*)(lib/gdk-pixbuf-2.0/2.10.0/loaders/[^"]*[.]dll)#../\2#' > "${PKG_DIR}"/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache
        rm "${PKG_DIR}"/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache.original
        # required icons
        mkdir "${PKG_DIR}"/share
        cp -rL /usr/share/icons "${PKG_DIR}"/share
        # compile glib settings schema
        mkdir -p "${PKG_DIR}"/share/glib-2.0
        cp -r /usr/${TARGET_ARCH_ID}-w64-mingw32/sys-root/mingw/share/glib-2.0/schemas "${PKG_DIR}"/share/glib-2.0
        glib-compile-schemas "${PKG_DIR}"/share/glib-2.0/schemas
        # add gtk configuration
        mkdir -p "${PKG_DIR}"/etc/gtk-3.0
        printf "[Settings]\ngtk-button-images=true\ngtk-font-name=Segoe UI 9\n" > "${PKG_DIR}"/etc/gtk-3.0/settings.ini

    - name: Package
      shell: bash
      run: cd '${{ steps.vars.outputs.PKG_DIR }}/' && ${{ steps.vars.outputs.COMPRESS_CMD }} '../${{ steps.vars.outputs.PKG_NAME }}' *

    - uses: actions/upload-artifact@v4
      if: matrix.job.publish
      with:
        name: ${{ steps.vars.outputs.PKG_NAME }}___ocaml-${{ matrix.job.ocaml-version }}.${{ matrix.job.os }}-publish
        path: ${{ steps.vars.outputs.STAGING_DIR }}/${{ steps.vars.outputs.PKG_NAME }}

    - name: Publish
      if: steps.vars.outputs.DEPLOY && matrix.job.publish
      uses: softprops/action-gh-release@v1
      with:
        files: |
          ${{ steps.vars.outputs.STAGING_DIR }}/${{ steps.vars.outputs.PKG_NAME }}
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    - if: runner.os == 'macOS'
      name: "macOS: Build and package Unison.app"
      id: macapp
      run: |
        opam exec -- make macui

        # package
        APP_NAME=Unison-${{ steps.vars.outputs.PKG_VER }}${{ matrix.job.fnsuffix }}.app.tar.gz
        echo APP_NAME=${APP_NAME} >> $GITHUB_OUTPUT

        tar czf ${APP_NAME} -C src/uimac/build/Default Unison.app

    - if: runner.os == 'macOS' && matrix.job.publish
      name: "macOS: Upload Unison.app artifact"
      uses: actions/upload-artifact@v4
      with:
        name: ${{ steps.macapp.outputs.APP_NAME }}___ocaml-${{ matrix.job.ocaml-version }}.${{ matrix.job.os }}-publish
        path: ${{ steps.macapp.outputs.APP_NAME }}

    - if: runner.os == 'macOS' && steps.vars.outputs.DEPLOY && matrix.job.publish
      name: "macOS: Publish Unison.app"
      uses: softprops/action-gh-release@v1
      with:
        files: ${{ steps.macapp.outputs.APP_NAME }}
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}


  rpc_abicheck:
    needs: build

    strategy:
      fail-fast: false
      matrix:
        # This list is intended to balance good enough coverage and
        # limited resource usage.
        job:
        - { os: ubuntu-22.04  , ocaml-version: 5.2.x   , ref: v2.53.5 }
        - { os: ubuntu-22.04  , ocaml-version: 4.14.x  , ref: v2.53.5 }
        - { os: ubuntu-22.04  , ocaml-version: 4.14.x  , ref: v2.52.1 , oldmake: true }
        - { os: ubuntu-22.04  , ocaml-version: 4.14.x  , ref: v2.51.5 , oldmake: true }
        - { os: ubuntu-22.04  , ocaml-version: 4.08.x  , ref: v2.51.5 , oldmake: true }
        - { os: ubuntu-22.04  , ocaml-version: 4.08.x  , ref: v2.51.2 , oldmake: true }
        - { os: windows-2019  , ocaml-version: ocaml-variants.4.14.0+mingw64c  , ref: v2.53.5 }
        - { os: windows-2019  , ocaml-version: ocaml-variants.4.14.0+mingw64c  , ref: v2.52.1 , oldmake: true }
        - { os: windows-2019  , ocaml-version: ocaml-variants.4.08.1+mingw32c  , ref: v2.51.5 , oldmake: true }
        - { os: macos-12      , ocaml-version: 4.14.x  , ref: v2.53.5 }
        - { os: macos-12      , ocaml-version: 4.14.x  , ref: v2.52.1 , oldmake: true }
        - { os: macos-12      , ocaml-version: 4.08.x  , ref: v2.51.2 , oldmake: true }

    runs-on: ${{ matrix.job.os }}

    steps:
    - if: contains(matrix.job.os, 'ubuntu')
      run: sudo apt-get update

    - name: Use OCaml ${{ matrix.job.ocaml-version }}
      uses: ocaml/setup-ocaml@v2
      with:
        ocaml-compiler: "${{ matrix.job.ocaml-version }}"
        opam-pin: false
        opam-depext: false
      # setup-ocaml can prepare the build environment from unison.opam
      # We're not relying on that capability here, to make sure the builds
      # also work without using unison.opam

    - name: Prepare Cygwin environment (Windows)
      if: runner.os == 'Windows'
      shell: cmd
      run: |
        echo %CYGWIN_ROOT_BIN%>> %GITHUB_PATH%
        echo %CYGWIN_ROOT_WRAPPERBIN%>> %GITHUB_PATH%
        echo SHELLOPTS=igncr>> %GITHUB_ENV%

    - name: Checkout HEAD to _new
      uses: actions/checkout@v4
      with:
        path: _new

    - run: cd _new && opam exec -- make tui
      shell: bash

    - name: Checkout ${{ matrix.job.ref }} to _prev
      uses: actions/checkout@v4
      with:
        ref: "${{ matrix.job.ref }}"
        path: _prev

    - name: "2.51.0: Patch connection header in _prev"
      if: contains(matrix.job.ref, '2.51.0')
      shell: bash
      run: |
        # Connection header string was broken in 2.51.0
        cd _prev && git apply - <<"EOF"
        diff --git a/src/remote.ml b/src/remote.ml
        index ddca0d77..7f819eab 100644
        --- a/src/remote.ml
        +++ b/src/remote.ml
        @@ -920,7 +920,7 @@ let connectionHeader =
             Scanf.sscanf Sys.ocaml_version "%d.%d.%d" (fun x y z -> (x,y,z)) in
           let compiler =
             if    major < 4 
        -       || major = 4 && minor <= 2
        +       || major = 4 && minor < 2
                || major = 4 && minor = 2 && patchlevel <= 1
             then "<= 4.01.1"
             else ">= 4.01.2"
        EOF

    - name: "2.51.{2,3}: Patch bugs in _prev"
      if: contains(matrix.job.ref, '2.51.2') || contains(matrix.job.ref, '2.51.3')
      shell: bash
      run: |
        cd _prev && git apply - <<"EOF"
        diff --git a/src/bytearray_stubs.c b/src/bytearray_stubs.c
        index 2b29421a..2850f2d8 100644
        --- a/src/bytearray_stubs.c
        +++ b/src/bytearray_stubs.c
        @@ -10,7 +10,7 @@
         CAMLprim value ml_marshal_to_bigarray(value v, value flags)
         {
           char *buf;
        -  long len;
        +  intnat len;
           output_value_to_malloc(v, flags, &buf, &len);
           return alloc_bigarray(BIGARRAY_UINT8 | BIGARRAY_C_LAYOUT | BIGARRAY_MANAGED,
                                 1, buf, &len);
        diff --git a/src/uicommon.ml b/src/uicommon.ml
        index 9fa94cf5..65fc37a5 100644
        --- a/src/uicommon.ml
        +++ b/src/uicommon.ml
        @@ -494,10 +494,11 @@ let promptForRoots getFirstRoot getSecondRoot =
         (* ---- *)
         
         let makeTempDir pattern =
        -  let ic = Unix.open_process_in (Printf.sprintf "(mktemp --tmpdir -d %s.XXXXXX || mktemp -d -t %s) 2>/dev/null" pattern pattern) in
        -  let path = input_line ic in
        -  ignore (Unix.close_process_in ic);
        -  path
        +  let path = Filename.temp_file pattern "" in
        +  let fspath = System.fspathFromString path in
        +  System.unlink fspath; (* Remove file created by [temp_file]... *)
        +  System.mkdir fspath 0o755; (* ... and create a dir instead. *)
        +  path ^ Filename.dir_sep
         
         (* The first time we load preferences, we also read the command line
            arguments; if we re-load prefs (because the user selected a new profile)
        EOF

    - name: "2.51.2: Patch _prev for mingw compilers"
      if: contains(matrix.job.ref, '2.51.2')
      shell: bash
      run: |
        cd _prev && git apply - <<"EOF"
        diff --git a/src/Makefile.OCaml b/src/Makefile.OCaml
        index 7cefa2ec..95b1bec4 100644
        --- a/src/Makefile.OCaml
        +++ b/src/Makefile.OCaml
        @@ -107,8 +107,8 @@ CAMLFLAGS+=-I system/$(SYSTEM) -I lwt/$(SYSTEM)
         ifeq ($(OSARCH),win32)
           # Win32 system
           EXEC_EXT=.exe
        -  OBJ_EXT=.obj
        -  OUTPUT_SEL=/Fo
        +  OBJ_EXT=.o
        +  OUTPUT_SEL=-o
           CWD=.
         #  Fix suggested by Karl M, Jan 2009:
         #    "The new flexlink wrapper that OCaml 3.11 uses was gagging on the res
        @@ -120,8 +120,6 @@ ifeq ($(OSARCH),win32)
           COBJS+=system/system_win_stubs$(OBJ_EXT) lwt/lwt_unix_stubs$(OBJ_EXT)
           WINOBJS=system/system_win.cmo
           SYSTEM=win
        -  CLIBS+=-cclib "-link win32rc/unison.res" shell32.lib
        -  STATICLIBS+=-cclib "-link win32rc/unison.res" shell32.lib
           buildexecutable::
         	@echo Building for Windows
         else
        EOF

    - name: "2.51.2: Patch _prev for newer compilers/OS"
      if: contains(matrix.job.ref, '2.51.2')
      shell: bash
      run: |
        cd _prev && git apply - <<"EOF"
        diff --git a/src/Makefile.OCaml b/src/Makefile.OCaml
        index 7cefa2ec..ffc3ab9e 100644
        --- a/src/Makefile.OCaml
        +++ b/src/Makefile.OCaml
        @@ -180,7 +180,7 @@ else
           buildexecutable:: $(NAME)$(EXEC_EXT)
         endif
         
        -MINOSXVERSION=10.5
        +MINOSXVERSION=10.7
         # XCODEFLAGS=-sdk macosx$(MINOSXVERSION)
         ifeq ($(OSARCH),osx)
           CAMLFLAGS+=-ccopt -mmacosx-version-min=$(MINOSXVERSION)
        diff --git a/src/files.ml b/src/files.ml
        index 5ff18810..1d1fbcc6 100644
        --- a/src/files.ml
        +++ b/src/files.ml
        @@ -734,7 +734,7 @@ let get_files_in_directory dir =
           with End_of_file ->
             dirh.System.closedir ()
           end;
        -  Sort.list (<) !files
        +  List.sort String.compare !files
        
         let ls dir pattern =
           Util.convertUnixErrorsToTransient
        diff --git a/src/recon.ml b/src/recon.ml
        index 2c619bb8..2412c18e 100644
        --- a/src/recon.ml
        +++ b/src/recon.ml
        @@ -661,8 +661,8 @@ let rec reconcile
        
         (* Sorts the paths so that they will be displayed in order                   *)
         let sortPaths pathUpdatesList =
        -  Sort.list
        -    (fun (p1, _) (p2, _) -> Path.compare p1 p2 <= 0)
        +  List.sort
        +    Path.compare
             pathUpdatesList
        
         let rec enterPath p1 p2 t =
        diff --git a/src/system/system_generic.ml b/src/system/system_generic.ml
        index 453027d0..290851e1 100755
        --- a/src/system/system_generic.ml
        +++ b/src/system/system_generic.ml
        @@ -47,7 +47,7 @@ let open_out_gen = open_out_gen
         let chmod = Unix.chmod
         let chown = Unix.chown
         let utimes = Unix.utimes
        -let link = Unix.link
        +let link x y = Unix.link x y
         let openfile = Unix.openfile
         let opendir f =
           let h = Unix.opendir f in
        EOF

    - name: "2.51.0 - 2.52.1: Patch tests in _prev"
      if: contains(matrix.job.ref, '2.51') || matrix.job.ref == 'v2.52.0' || matrix.job.ref == 'v2.52.1'
      shell: bash
      run: |
        cd _prev && git apply - <<"EOF"
        diff --git a/src/test.ml b/src/test.ml
        index 3d480409..60ed014d 100644
        --- a/src/test.ml
        +++ b/src/test.ml
        @@ -542,6 +542,77 @@ let test() =
             *)
           end;
         
        +  if not bothRootsLocal then
        +  begin
        +    let localR, remoteR, localRaw =
        +      match r1 with
        +      | Common.Local, _ -> R1, R2, r1
        +      | _ -> R2, R1, r2
        +    in
        +
        +    (* Test RPC function "fingerprintSubfile" *)
        +    runtest "RPC: transfer append" [] (fun () ->
        +      let prefixLen = 1024 * 1024 + 1 in
        +      let len = prefixLen + 31 in
        +      let contents = String.make len '.' in
        +      let fileName = "bigfile" in
        +      let prefixPath = Path.fromString fileName in
        +      let (workingDir, _) = Fspath.findWorkingDir (snd localRaw) prefixPath in
        +      let prefixName = Path.toString (Os.tempPath ~fresh:false workingDir prefixPath) in
        +      put remoteR (Dir [(fileName, File contents)]);
        +      put localR (Dir [(prefixName, File (String.sub contents 0 prefixLen))]);
        +      sync ();
        +      check "1" localR (Dir [(fileName, File contents)]);
        +    );
        +
        +    (* Test RPC function "updateProps" *)
        +    runtest "RPC: update props" ["times = true"] (fun () ->
        +      let state = [("a", File "x")] in
        +      put remoteR (Dir state);
        +      put localR (Dir []);
        +      sync ();
        +      (* Having to sleep here is an unfortunate side-effect of the current
        +         Windows limitations-inspired time comparison algorithm which is
        +         designed to work on FAT filesystems (2-second granularity). *)
        +      Unix.sleep 2;
        +      put remoteR (Dir state);
        +      sync ();
        +      check "1" localR (Dir state);
        +    );
        +
        +    (* Test RPC function "replaceArchive" *)
        +    runtest "RPC: replaceArchive" [] (fun () ->
        +      put localR (Dir [("n", File "to delete")]);
        +      put remoteR (Dir []);
        +      sync ();
        +      put remoteR (Dir []);
        +      sync ();
        +      check "1" localR (Dir []);
        +    );
        +
        +    (* Test RPC functions "mkdir" and "setDirProp" *)
        +    runtest "RPC: mkdir, setDirProp" [] (fun () ->
        +      let state = [("subd", Dir [])] in
        +      put localR (Dir state);
        +      put remoteR (Dir []);
        +      sync ();
        +      check "1" remoteR (Dir state);
        +    );
        +
        +    (* Test RPC function "setupTargetPaths" *)
        +    runtest "RPC: merge" ["merge = Name ma -> echo x> NEW"; "backupcurr = Name ma"] (fun () ->
        +      let result = match Sys.os_type with
        +        | "Win32" -> ("ma", File "x\r\n")
        +        | _ -> ("ma", File "x\n")
        +      in
        +      put localR (Dir [("ma", File "a")]);
        +      put remoteR (Dir [("ma", File "b")]);
        +      sync ();
        +      check "1" localR (Dir [result]);
        +      check "2" remoteR (Dir [result]);
        +    );
        +  end;
        +
           if !failures = 0 then
             Util.msg "Success :-)\n"
           else
        EOF

    - name: "2.52.0: Patch _prev for newer compilers"
      if: contains(matrix.job.ref, '2.52.0')
      shell: bash
      run: |
        cd _prev && git apply - <<"EOF"
        diff --git a/src/system/system_win_stubs.c b/src/system/system_win_stubs.c
        index 50ea663f..57940d98 100644
        --- a/src/system/system_win_stubs.c
        +++ b/src/system/system_win_stubs.c
        @@ -373,7 +373,7 @@ typedef enum _FILE_INFORMATION_CLASS {
         #include <caml/version.h> /* Available since OCaml 4.02 */
         #endif
        
        -#if !defined(OCAML_VERSION) || OCAML_VERSION < 40300
        +#if !defined(OCAML_VERSION) || OCAML_VERSION < 40300 || OCAML_VERSION >= 41400
        
         typedef struct _REPARSE_DATA_BUFFER {
           ULONG  ReparseTag;
        EOF

    - name: "2.52 - 2.53.3: Patch _prev for newer compilers"
      if: contains(matrix.job.ref, '2.52') || matrix.job.ref == 'v2.53.0' || matrix.job.ref == 'v2.53.1' || matrix.job.ref == 'v2.53.2' || matrix.job.ref == 'v2.53.3'
      shell: bash
      run: |
        cd _prev && git apply - <<"EOF"
        diff --git a/src/lwt/lwt_unix_stubs.c b/src/lwt/lwt_unix_stubs.c
        index 37154710..51caabff 100644
        --- a/src/lwt/lwt_unix_stubs.c
        +++ b/src/lwt/lwt_unix_stubs.c
        @@ -392,8 +392,8 @@ CAMLprim value win_check_connection (value socket, value kind, value h) {
        
         static HANDLE dummyEvent;
        
        -CAMLprim value init_lwt (value callback) {
        -  CAMLparam1 (callback);
        +CAMLprim value init_lwt (value callb) {
        +  CAMLparam1 (callb);
           //  GUID GuidConnectEx = WSAID_CONNECTEX;
           //  SOCKET s;
           //  DWORD l;
        @@ -401,7 +401,7 @@ CAMLprim value init_lwt (value callback) {
        
           D(printf("Init...\n"));
           caml_register_global_root(&completionCallback);
        -  completionCallback = callback;
        +  completionCallback = callb;
        
           dummyEvent = CreateEvent(NULL, TRUE, FALSE, NULL);  // Dummy event
        
        EOF

    - run: cd _prev && opam exec -- make src UISTYLE=text OSTYPE=$OSTYPE
      shell: bash
      if: matrix.job.oldmake

    - run: cd _prev && opam exec -- make tui
      shell: bash
      if: ${{ !matrix.job.oldmake }}

    # IMPORTANT! These tests do not exercise the entire RPC API. Yet, they
    # should work fine as a smoke test.

    - name: Run self-tests over RPC - new client to prev server
      env:
        UNISON: test1
      shell: bash
      run: |
        # Separate backup dir must be set for server instance so that the central
        # backup location of both instances doesn't overlap
        UNISONBACKUPDIR=./_prev/src/testbak_s _prev/src/unison -socket 55443 &
        sleep 1 # Wait for the server to be fully started
        _new/src/unison -ui text -selftest testr_c socket://127.0.0.1:55443/testr_s -killserver

    - name: Run self-tests over RPC - prev client to new server
      env:
        UNISON: test2
      shell: bash
      run: |
        # Separate backup dir must be set for server instance so that the central
        # backup location of both instances doesn't overlap
        UNISONBACKUPDIR=./_new/src/testbak_s _new/src/unison -socket 55443 &
        sleep 1 # Wait for the server to be fully started
        _prev/src/unison -ui text -selftest testr_c socket://127.0.0.1:55443/testr_s -killserver


  ## We know the code is ok with various ocaml versions, so this is
  ## just checking the dune build process.  Therefore build each OS
  ## family just once.  Pick a different ocaml version because that's
  ## better coverage without adding a build.
  opam_dune_build:
    strategy:
      fail-fast: false
      matrix:
        job:
        - { os: ubuntu-22.04   , ocaml-compiler: 4.12.x }
        - { os: macos-14       , ocaml-compiler: 4.14.x }

    runs-on: ${{ matrix.job.os }}

    steps:
    - if: contains(matrix.job.os, 'ubuntu')
      run: sudo apt-get update

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

    - name: Use OCaml ${{ matrix.job.ocaml-compiler }}
      uses: ocaml/setup-ocaml@v2
      with:
        ocaml-compiler: "${{ matrix.job.ocaml-compiler }}"

    - run: opam install . --deps-only

    - run: opam exec -- dune build && cp -L ./_build/install/default/bin/unison* ./src/

#    - run: opam exec -- make test

  bytecode_build:
    strategy:
      fail-fast: false
      matrix:
        job:
        - { os: ubuntu-22.04   , ocaml-compiler: 4.14.x }

    runs-on: ${{ matrix.job.os }}

    steps:
    - if: contains(matrix.job.os, 'ubuntu')
      run: sudo apt-get update

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

    - name: Use OCaml ${{ matrix.job.ocaml-compiler }}
      uses: ocaml/setup-ocaml@v2
      with:
        ocaml-compiler: "${{ matrix.job.ocaml-compiler }}"
        opam-pin: false
        opam-depext: false

    - run: opam exec -- make tui NATIVE=false

    - run: opam exec -- make test


  build_compat:
    if: ${{ !cancelled() }}    # Don't fail if 'docs' failed
    needs: docs

    strategy:
      fail-fast: false
      matrix:
        job:
        - { ocaml-version: 4.14.x, publish: true, fnsuffix: -ubuntu-x86_64 }
        - { ocaml-version: "ocaml-variants.4.14.2+options,ocaml-option-musl,ocaml-option-static,ocaml-option-flambda", publish: true, fnsuffix: -ubuntu-x86_64-static, static: -static }
        - { ocaml-version: 4.12.x }
        - { ocaml-version: 4.08.x }

    runs-on: ubuntu-20.04

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

    - name: Use OCaml ${{ matrix.job.ocaml-version }}
      uses: ocaml/setup-ocaml@v2
      with:
        ocaml-compiler: ${{ matrix.job.ocaml-version }}
        opam-disable-sandboxing: true
        opam-pin: false
        opam-depext: false

    - name: Build text UI
      env:
        LDFLAGS: ${{ matrix.job.static }}
      run: |
        opam exec -- make
        mkdir -p pkg/bin
        cp src/unison pkg/bin/
        cp src/unison-fsmonitor pkg/bin/

    - name: Run local tests
      run: opam exec -- make test

    - name: Run remote tests
      run: |
        mkdir localsocket
        chmod 700 localsocket
        # Separate backup dir must be set for server instance so that the central
        # backup location of both instances doesn't overlap
        UNISONBACKUPDIR=./src/testbak4 ./src/unison -socket ./localsocket/test.sock &
        sleep 1 # Wait for the server to be fully started
        test -S ./localsocket/test.sock
        ./src/unison -ui text -selftest testr3 socket://{./localsocket/test.sock}/testr4 -killserver

    - name: Build GUI
      if: ${{ !contains(matrix.job.ocaml-version, '-musl') }}
      run: |
        opam depext --install --verbose --yes lablgtk3 && opam install ocamlfind
        opam exec -- make gui
        cp src/unison-gui pkg/bin/

    - name: Initialize packaging variables
      id: vars
      run: |
        REF_SHAS=$(echo '${{ github.sha }}' | awk '{ print substr($0, 1, 8) }')
        unset REF_TAG ; case "${GITHUB_REF}" in refs/tags/*) REF_TAG="${GITHUB_REF#refs/tags/}" ;; esac;
        PKG_VER="${REF_TAG:-git_$REF_SHAS}"
        PKG_VER="${PKG_VER#v}"
        echo PKG_NAME="${PROJECT_NAME}-${PKG_VER}${{ matrix.job.fnsuffix }}.tar.gz" >> $GITHUB_OUTPUT
        echo REF_SHAS=${REF_SHAS} >> $GITHUB_OUTPUT

    - uses: actions/upload-artifact@v4
      if: false ## disable by default; only useful for debugging GHA
      with:
        name: unison-${{ steps.vars.outputs.REF_SHAS }}.ocaml-${{ matrix.job.ocaml-version }}.ubuntu.x86_64
        path: pkg/bin/*

    - name: Copy user manual
      if: matrix.job.publish
      continue-on-error: ${{ !(github.ref_type == 'tag' && startsWith(github.ref_name, 'v') && matrix.job.publish) }}
      uses: actions/download-artifact@v4
      with:
        name: unison-docs
        path: pkg

    - name: Prepare package
      if: matrix.job.publish
      run: |
        strip pkg/bin/*
        cp README* pkg/
        cp LICENSE* pkg/
        cp INSTALL.md pkg/
        cp CONTRIBUTING.md pkg/
        cp NEWS.md pkg/

    - name: Package
      if: matrix.job.publish
      run: cd pkg && tar czf '${{ steps.vars.outputs.PKG_NAME }}' *

    - uses: actions/upload-artifact@v4
      if: matrix.job.publish
      with:
        name: ${{ steps.vars.outputs.PKG_NAME }}___ocaml-${{ matrix.job.ocaml-version }}.ubuntu_compat-publish
        path: pkg/${{ steps.vars.outputs.PKG_NAME }}

    - name: Publish
      if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') && matrix.job.publish
      uses: softprops/action-gh-release@v1
      with:
        files: pkg/${{ steps.vars.outputs.PKG_NAME }}
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
