diff --git a/.github/actions/publish-prebuilt-release/action.yml b/.github/actions/publish-prebuilt-release/action.yml new file mode 100644 index 0000000..f4e21d4 --- /dev/null +++ b/.github/actions/publish-prebuilt-release/action.yml @@ -0,0 +1,62 @@ +name: Publish prebuilt prerelease +description: >- + Tag preflight + SHA256SUMS + `gh release create` shared by the manual + prebuild-* workflows. Caller must declare contents:write — composite + actions cannot grant their own permissions. + +inputs: + tag: + required: true + description: GH Release tag (must not already exist). + title: + required: true + description: Release title. + notes: + required: true + description: Release notes (markdown body). + asset-glob: + required: true + description: Glob (matching files in dist/) for assets to attach. SHA256SUMS appended automatically. + bump-hint: + required: true + description: Env var name to mention in the duplicate-tag error. + github-token: + required: true + description: Pass secrets.GITHUB_TOKEN from the caller — composite actions cannot read secrets directly. + +runs: + using: composite + steps: + - name: Fail if release already exists + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + TAG: ${{ inputs.tag }} + BUMP: ${{ inputs.bump-hint }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "::error::Release $TAG already exists. Bump $BUMP in versions.env first." + exit 1 + fi + + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Create release + shell: bash + # ASSET_GLOB intentionally unquoted so the shell expands it. + # Caller strings flow through env (not interpolated into shell + # directly) so quotes in titles/notes need no escaping. + env: + GH_TOKEN: ${{ inputs.github-token }} + TAG: ${{ inputs.tag }} + TITLE: ${{ inputs.title }} + NOTES: ${{ inputs.notes }} + ASSET_GLOB: ${{ inputs.asset-glob }} + run: | + cd dist + sha256sum $ASSET_GLOB > SHA256SUMS + gh release create "$TAG" --title "$TITLE" --notes "$NOTES" --prerelease \ + $ASSET_GLOB SHA256SUMS diff --git a/.github/actions/setup-haskell-env/action.yml b/.github/actions/setup-haskell-env/action.yml new file mode 100644 index 0000000..6444b6a --- /dev/null +++ b/.github/actions/setup-haskell-env/action.yml @@ -0,0 +1,153 @@ +name: Setup Haskell build environment +description: MSYS2/apt/brew toolchain + GHCup install (with optional cache restore on Unix). Caller adds the matching cache-save step — composite actions cannot defer steps until after the caller's downstream work. + +inputs: + matrix-os: { required: true, description: linux | windows | macos } + matrix-name: { required: true, description: matrix entry name (e.g. linux-amd64), used in the GHCup cache key } + ghc-version: { required: false, default: '', description: GHC version to install. Empty skips every Cabal/GHC/libquadmath step (used by the MUMPS-only prebuild). } + install-upx: { required: false, default: 'false', description: Install UPX for binary compression. } + cache-ghcup: { required: false, default: 'false', description: Restore GHCup install from actions/cache on Linux/macOS. } + +outputs: + ghcup-cache-hit: { description: Output of actions/cache/restore — empty when cache-ghcup is false. , value: "${{ steps.cache.outputs.cache-hit }}" } + ghcup-cache-key: { description: Primary cache key for the matching save step., value: "${{ steps.cache.outputs.cache-primary-key }}" } + +runs: + using: composite + steps: + - name: Setup MSYS2 (Windows) + if: inputs.matrix-os == 'windows' + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + cache: true + install: >- + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-gcc-fortran + mingw-w64-ucrt-x86_64-openblas + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-make + ${{ inputs.install-upx == 'true' && 'mingw-w64-ucrt-x86_64-python' || '' }} + ${{ inputs.install-upx == 'true' && 'mingw-w64-ucrt-x86_64-upx' || '' }} + make python git curl tar + + # C:\cabal -> D:\cabal junction. C: is a slow Azure managed disk + # (~4k IOPS); D: is local temp (~83k IOPS). Junction (vs. CABAL_DIR + # override) keeps the prebuilt cabal-store tarball working unchanged + # — its .conf files have absolute C:\cabal\store\... paths baked in. + - name: Redirect C:\cabal to D:\cabal (Windows) + if: inputs.matrix-os == 'windows' && inputs.ghc-version != '' + shell: cmd + run: | + if exist C:\cabal rmdir /s /q C:\cabal + mkdir D:\cabal + mklink /J C:\cabal D:\cabal + + # GHCup on Windows installs fresh into D:\ghcup every run: caching + # C:\ghcup took ~26 min to save (tar + zstd + upload of ~2.5 GB + # of small NTFS files) vs. ~2 min to reinstall. + - name: Install GHC via ghcup (Windows) + if: inputs.matrix-os == 'windows' && inputs.ghc-version != '' + shell: 'msys2 {0}' + run: | + curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \ + | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ + BOOTSTRAP_HASKELL_GHC_VERSION=${{ inputs.ghc-version }} \ + BOOTSTRAP_HASKELL_INSTALL_NO_STACK=1 \ + GHCUP_INSTALL_BASE_PREFIX=/d \ + sh + # MSYS2's CMD wrapper does not propagate GITHUB_PATH, so later + # steps in the calling workflow re-source /d/ghcup/env themselves. + source /d/ghcup/env + ghc --version && cabal --version && cabal update + + - name: Install build dependencies (Linux) + if: inputs.matrix-os == 'linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential gfortran cmake python3 curl \ + zlib1g-dev liblapack-dev libblas-dev \ + ${{ inputs.install-upx == 'true' && 'upx-ucl' || '' }} + # libquadmath workaround: gcc's gfortran spec auto-adds -lquadmath + # when linking gfortran archives (libdmumps_seq.a). amd64 ships + # /usr/lib/x86_64-linux-gnu/libquadmath.so; arm64 noble ships + # nothing — link fails. Symlink to runtime if present, else stub + # (MUMPS double-precision never calls __quadmath_* at runtime). + # Skipped when ghc-version is empty (MUMPS-only prebuild never + # runs the link step that needs quadmath). + if [[ -n "${{ inputs.ghc-version }}" ]]; then + LIBDIR=/usr/lib/$(gcc -print-multiarch) + [[ -d "$LIBDIR" ]] || LIBDIR=/usr/lib + if [[ ! -e "$LIBDIR/libquadmath.so" ]]; then + if [[ -e "$LIBDIR/libquadmath.so.0" ]]; then + sudo ln -s libquadmath.so.0 "$LIBDIR/libquadmath.so" + else + echo 'void __libquadmath_stub(void) {}' \ + | sudo gcc -shared -x c -o "$LIBDIR/libquadmath.so" - + fi + fi + fi + + - name: Install build dependencies (macOS) + if: inputs.matrix-os == 'macos' + shell: bash + run: brew install gcc openblas ${{ inputs.install-upx == 'true' && 'upx' || '' }} + + - name: Restore GHCup cache (Linux/macOS) + id: cache + if: (inputs.matrix-os == 'linux' || inputs.matrix-os == 'macos') && inputs.ghc-version != '' && inputs.cache-ghcup == 'true' + uses: actions/cache/restore@v4 + with: + path: ~/.ghcup + key: ghcup-${{ inputs.matrix-name }}-ghc${{ inputs.ghc-version }}-v4 + + # Bootstrap or restore GHCup into ~/.ghcup. We avoid haskell-actions/setup + # because it symlinks to the runner's preinstalled /usr/local/.ghcup, + # leaving ~/.ghcup empty of cacheable bytes. We also wipe the runner's + # façade ~/.ghcup symlinks before install — otherwise tar archives the + # ~200-byte symlink shells, not the targets. GHCUP_INSTALL_BASE_PREFIX + # override pins install to ~/.ghcup (runner image defaults it to + # /usr/local globally) and is also needed for `ghcup set` on warm runs. + - name: Install GHC + Cabal via ghcup (Linux/macOS) + if: (inputs.matrix-os == 'linux' || inputs.matrix-os == 'macos') && inputs.ghc-version != '' + shell: bash + env: + GHC_VER: ${{ inputs.ghc-version }} + run: | + export GHCUP_INSTALL_BASE_PREFIX="$HOME" + # Pin CABAL_DIR=~/.cabal so legacy paths win regardless of + # cabal version / runner-image XDG defaults — otherwise ubuntu's + # pre-created ~/.config/cabal/config redirects the store under + # XDG_STATE_HOME and the cached ~/.cabal/store gets bypassed. + # Both `export` (for the cabal commands later in this step) and + # `>> $GITHUB_ENV` (for subsequent workflow steps) are needed: + # GITHUB_ENV writes don't take effect until the next step. + export CABAL_DIR="$HOME/.cabal" + echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" + if [[ -d "$HOME/.ghcup/ghc/$GHC_VER" ]]; then + # Warm: re-assert the bare `ghc` symlink in case the snapshot + # was taken with another version active. + "$HOME/.ghcup/bin/ghcup" set ghc "$GHC_VER" + else + rm -rf "$HOME/.ghcup" + curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \ + | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ + BOOTSTRAP_HASKELL_GHC_VERSION="$GHC_VER" \ + BOOTSTRAP_HASKELL_INSTALL_NO_STACK=1 \ + BOOTSTRAP_HASKELL_ADJUST_BASHRC=no \ + GHCUP_INSTALL_BASE_PREFIX="$HOME" \ + sh + [[ -x "$HOME/.ghcup/bin/ghc-$GHC_VER" ]] || { + echo "::error::ghcup did not install GHC $GHC_VER to ~/.ghcup as expected" + exit 1 + } + fi + echo "$HOME/.ghcup/bin" >> "$GITHUB_PATH" + "$HOME/.ghcup/bin/ghc" --numeric-version + # ~/.cabal/packages (Hackage index) lives outside ~/.ghcup, so + # always refresh — cold installs need it, warm runs need a + # current view for the solver. + "$HOME/.ghcup/bin/cabal" update diff --git a/.github/workflows/_build-matrix.yml b/.github/workflows/_build-matrix.yml index 85faa0d..89e14c8 100644 --- a/.github/workflows/_build-matrix.yml +++ b/.github/workflows/_build-matrix.yml @@ -15,10 +15,21 @@ on: type: boolean default: false +# Read-only token: this workflow only checks out the repo and downloads +# from public release artifacts. Publishing to releases is done by the +# callers (release.yml, prebuild-*.yml) under their own elevated scope. +permissions: + contents: read + jobs: build: name: ${{ matrix.name }} runs-on: ${{ matrix.runner }} + # Catches genuine hangs without false-failing legitimate cold builds. + # Windows cold path (MSYS2 + GHCup install + cold cabal compile + 25-min + # test step) tops out around 50 min; 60 min gives ~10 min headroom while + # still being well under GH's default 360-min cap. + timeout-minutes: 60 strategy: fail-fast: false matrix: @@ -63,204 +74,15 @@ jobs: ver=$(awk '/^version:/ {print $2; exit}' volca.cabal) echo "version=$ver" >> "$GITHUB_OUTPUT" - - name: Setup MSYS2 (Windows) - if: matrix.os == 'windows' - uses: msys2/setup-msys2@v2 - with: - msystem: UCRT64 - update: true - cache: true - install: >- - mingw-w64-ucrt-x86_64-gcc - mingw-w64-ucrt-x86_64-gcc-fortran - mingw-w64-ucrt-x86_64-openblas - mingw-w64-ucrt-x86_64-cmake - mingw-w64-ucrt-x86_64-make - mingw-w64-ucrt-x86_64-python - mingw-w64-ucrt-x86_64-upx - make - python - git - curl - tar - - # Redirect C:\cabal -> D:\cabal via NTFS directory junction. Cabal - # on Windows defaults CABAL_DIR to C:\cabal (store, packages - # index, config). C: is a slow Azure managed disk on GH-hosted - # runners (~4k IOPS); D: is local temp storage (~83k IOPS, 20×). - # - # Using a junction instead of CABAL_DIR=D:\cabal lets us keep the - # existing cabal-store-prebuilt tarball working unchanged: its - # .conf files have absolute C:\cabal\store\... paths baked in - # (cabal-install on Windows doesn't use ${pkgroot}), so any other - # approach would force a re-prebuild + CABAL_PREBUILT_REVISION - # bump. The junction is filesystem-level and transparent — every - # C:\cabal\* read/write hits D: at the OS layer. - - name: Redirect C:\cabal to D:\cabal (Windows IOPS) - if: matrix.os == 'windows' - shell: cmd - run: | - if exist C:\cabal rmdir /s /q C:\cabal - mkdir D:\cabal - mklink /J C:\cabal D:\cabal - - # GHCup on Windows: install fresh every run, into D:\ghcup. - # - # Two things going on: - # - # 1. We previously cached C:\ghcup with actions/cache, but the Save - # step took ~26 min (tar + zstd + upload of ~2.5 GB across - # thousands of small NTFS files) — vs. ~2 min for the install - # itself. Even on cache-hit runs the saving was net-negative once - # 7-day eviction + per-branch scoping were factored in. The - # Linux/macOS cache is fine; only Windows is pathologically slow. - # - # 2. GitHub-hosted Windows runners are Azure VMs where C: is a remote - # managed disk (~4k IOPS) while D: is local temp storage - # (~83k IOPS, 20× faster). GHCup defaults to C:\ghcup; pinning it - # to D:\ghcup via GHCUP_INSTALL_BASE_PREFIX puts every subsequent - # ghc/cabal read on the fast disk for the rest of the job. D: is - # ephemeral (lost on VM redeploy) but GH runners are ephemeral - # too, so that doesn't matter. - - name: Install GHC via ghcup (Windows MSYS2) - if: matrix.os == 'windows' - run: | - curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \ - | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ - BOOTSTRAP_HASKELL_GHC_VERSION=${{ steps.versions.outputs.ghc }} \ - BOOTSTRAP_HASKELL_INSTALL_NO_STACK=1 \ - GHCUP_INSTALL_BASE_PREFIX=/d \ - sh - - - name: Verify GHC + Cabal (Windows) - if: matrix.os == 'windows' - run: | - # GHCup on Windows installs to /d/ghcup (see comment above). - # Subsequent MSYS2 steps need to source this themselves — the - # msys2.CMD wrapper does not propagate GITHUB_PATH. - source /d/ghcup/env - ghc --version - cabal --version - cabal update - - - name: Install build dependencies (Linux) - if: matrix.os == 'linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential gfortran cmake python3 curl \ - zlib1g-dev liblapack-dev libblas-dev upx-ucl - # gcc's gfortran spec auto-adds -lquadmath when linking - # gfortran archives (libdmumps_seq.a). amd64 ships - # /usr/lib/x86_64-linux-gnu/libquadmath.so; arm64 noble ships - # no libquadmath at all and the link fails with - # "ld: cannot find -lquadmath". Symlink to the runtime if - # present, otherwise generate an empty stub — MUMPS in - # double-precision does not call __quadmath_* at runtime. - LIBDIR=/usr/lib/$(gcc -print-multiarch) - [[ -d "$LIBDIR" ]] || LIBDIR=/usr/lib - if [[ ! -e "$LIBDIR/libquadmath.so" ]]; then - if [[ -e "$LIBDIR/libquadmath.so.0" ]]; then - sudo ln -s libquadmath.so.0 "$LIBDIR/libquadmath.so" - echo "Symlinked $LIBDIR/libquadmath.so -> libquadmath.so.0" - else - echo 'void __libquadmath_stub(void) {}' \ - | sudo gcc -shared -x c -o "$LIBDIR/libquadmath.so" - - echo "Stubbed $LIBDIR/libquadmath.so (empty)" - fi - fi - - - name: Install build dependencies (macOS) - if: matrix.os == 'macos' - run: brew install gcc openblas upx - - - name: Restore GHCup install (Linux/macOS) - id: cache-ghcup-unix - if: matrix.os == 'linux' || matrix.os == 'macos' - uses: actions/cache/restore@v4 + - name: Setup Haskell build environment + id: setup-haskell + uses: ./.github/actions/setup-haskell-env with: - path: ~/.ghcup - key: ghcup-${{ matrix.name }}-ghc${{ steps.versions.outputs.ghc }}-v4 - - - name: Pin cabal store dir (Linux/macOS) - # Newer cabal-install versions look at $XDG_CONFIG_HOME/cabal/config - # and put the package store under $XDG_STATE_HOME/cabal/store. The - # ubuntu-latest runner image pre-creates ~/.config/cabal/config, so - # cabal "ignores the former" (~/.cabal) and our cached ~/.cabal/store - # gets bypassed entirely — every warm run did a full cold dep - # rebuild. Pin CABAL_DIR=~/.cabal so the legacy paths win regardless - # of cabal version or runner-image state. - if: matrix.os == 'linux' || matrix.os == 'macos' - run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" - - - name: Install GHC + Cabal via ghcup (Linux/macOS, cold) - # We bootstrap ghcup ourselves into ~/.ghcup so that actions/cache - # has real content to archive (haskell-actions/setup@v2 just symlinks - # back to the runner's preinstalled /usr/local/.ghcup, leaving - # ~/.ghcup empty of cacheable bytes). - # - # Two tricks needed: - # 1. The runner image exports GHCUP_INSTALL_BASE_PREFIX=/usr/local - # globally, redirecting installs to /usr/local/.ghcup. Override - # to $HOME so install goes to ~/.ghcup. - # 2. The runner image pre-creates ~/.ghcup as a façade directory - # containing SYMLINKS into /usr/local/.ghcup (e.g. `share -> - # ./ghc/9.14.1/share`). ghcup install writes through those - # symlinks, but tar archives the symlink shells (~200 bytes) - # not the targets. Wipe ~/.ghcup first so our install lands in - # a real, cacheable directory tree. (rm doesn't follow symlinks, - # so /usr/local/.ghcup is untouched — but we don't need it - # anyway because $PATH below points only at ~/.ghcup/bin.) - if: (matrix.os == 'linux' || matrix.os == 'macos') && steps.cache-ghcup-unix.outputs.cache-hit != 'true' - run: | - rm -rf "$HOME/.ghcup" - export GHCUP_INSTALL_BASE_PREFIX="$HOME" - curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \ - | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ - BOOTSTRAP_HASKELL_GHC_VERSION=${{ steps.versions.outputs.ghc }} \ - BOOTSTRAP_HASKELL_INSTALL_NO_STACK=1 \ - BOOTSTRAP_HASKELL_ADJUST_BASHRC=no \ - GHCUP_INSTALL_BASE_PREFIX="$HOME" \ - sh - # Sanity-check: bail loudly if the install ended up somewhere we - # can't cache, so we don't silently regress the warm path. - if [[ ! -x "$HOME/.ghcup/bin/ghc-${{ steps.versions.outputs.ghc }}" ]]; then - echo "::error::ghcup did not install to ~/.ghcup as expected" - ls -la "$HOME/.ghcup/" "$HOME/.ghcup/bin/" 2>&1 || true - exit 1 - fi - echo "[INFO] ~/.ghcup install size: $(du -sh "$HOME/.ghcup" | cut -f1)" - echo "$HOME/.ghcup/bin" >> "$GITHUB_PATH" - "$HOME/.ghcup/bin/ghc" --numeric-version - "$HOME/.ghcup/bin/cabal" --numeric-version - "$HOME/.ghcup/bin/cabal" update - - - name: Use cached GHC + Cabal (Linux/macOS, warm) - if: (matrix.os == 'linux' || matrix.os == 'macos') && steps.cache-ghcup-unix.outputs.cache-hit == 'true' - shell: bash - run: | - # The runner image exports GHCUP_INSTALL_BASE_PREFIX=/usr/local - # globally; without overriding it here, `ghcup set ghc` would look - # for our pinned version under /usr/local/.ghcup/ghc/ and fail with - # "version 9.6.7 of the tool ghc is not installed". Match the cold - # step's override. - export GHCUP_INSTALL_BASE_PREFIX="$HOME" - GHC_VER="${{ steps.versions.outputs.ghc }}" - if [[ ! -d "$HOME/.ghcup/ghc/$GHC_VER" ]]; then - echo "::error::Cached ~/.ghcup is missing GHC $GHC_VER (stale cache key?)" - ls -la "$HOME/.ghcup/" "$HOME/.ghcup/ghc/" 2>&1 || true - exit 1 - fi - # Force the bare `ghc` symlink to our pinned version — bootstrap - # auto-sets it but we re-assert here in case the cache snapshot - # was taken in a state where another version was active. - "$HOME/.ghcup/bin/ghcup" set ghc "$GHC_VER" - echo "$HOME/.ghcup/bin" >> "$GITHUB_PATH" - "$HOME/.ghcup/bin/ghc" --numeric-version - "$HOME/.ghcup/bin/cabal" --numeric-version - # ~/.cabal/packages (Hackage index) lives outside ~/.ghcup and is - # not in the cache; refresh it so cabal solve has a current view. - "$HOME/.ghcup/bin/cabal" update + matrix-os: ${{ matrix.os }} + matrix-name: ${{ matrix.name }} + ghc-version: ${{ steps.versions.outputs.ghc }} + install-upx: 'true' + cache-ghcup: 'true' # MUMPS sourcing has three layers, tried in order: # @@ -479,17 +301,17 @@ jobs: - name: Save GHCup install (Linux/macOS) # always() so a downstream test or optimize failure doesn't discard - # the GHC install we just paid 3–4 minutes for. The install completes - # in a single `Setup GHC + Cabal (Linux/macOS, cold)` step; if THAT - # step fails, the workflow halts before this save runs anyway, so - # there's no risk of caching a partial ~/.ghcup. (Contrast with the - # Windows pattern, which is split across multiple steps and prefers - # the safer no-always policy.) - if: always() && (matrix.os == 'linux' || matrix.os == 'macos') && steps.cache-ghcup-unix.outputs.cache-hit != 'true' + # the GHC install we just paid 3–4 minutes for. The cold install + # completes within the setup-haskell-env composite; if THAT fails + # the workflow halts before this save runs anyway, so there's no + # risk of caching a partial ~/.ghcup. (Contrast with the Windows + # pattern, which is split across multiple steps and prefers the + # safer no-always policy.) + if: always() && (matrix.os == 'linux' || matrix.os == 'macos') && steps.setup-haskell.outputs.ghcup-cache-hit != 'true' uses: actions/cache/save@v4 with: path: ~/.ghcup - key: ${{ steps.cache-ghcup-unix.outputs.cache-primary-key }} + key: ${{ steps.setup-haskell.outputs.ghcup-cache-key }} - name: Save Cabal store # Save even when the prebuilt download wins. Two reasons: diff --git a/.github/workflows/prebuild-cabal-store.yml b/.github/workflows/prebuild-cabal-store.yml index 1d1392c..b1ac55b 100644 --- a/.github/workflows/prebuild-cabal-store.yml +++ b/.github/workflows/prebuild-cabal-store.yml @@ -60,100 +60,17 @@ jobs: run: | source versions.env echo "ghc=$GHC_VERSION" >> "$GITHUB_OUTPUT" - echo "mumps=$MUMPS_VERSION" >> "$GITHUB_OUTPUT" - echo "mumps_rev=${MUMPS_PREBUILT_REVISION:-1}" >> "$GITHUB_OUTPUT" - - name: Setup MSYS2 (Windows) - if: matrix.os == 'windows' - uses: msys2/setup-msys2@v2 + - name: Setup Haskell build environment + uses: ./.github/actions/setup-haskell-env with: - msystem: UCRT64 - update: true - install: >- - mingw-w64-ucrt-x86_64-gcc - mingw-w64-ucrt-x86_64-gcc-fortran - mingw-w64-ucrt-x86_64-openblas - mingw-w64-ucrt-x86_64-cmake - mingw-w64-ucrt-x86_64-make - make - python - git - curl - tar - - # Redirect C:\cabal -> D:\cabal so every cabal write hits the - # fast local temp disk. Same rationale + same junction trick as - # _build-matrix.yml. - - name: Redirect C:\cabal to D:\cabal (Windows IOPS) - if: matrix.os == 'windows' - shell: cmd - run: | - if exist C:\cabal rmdir /s /q C:\cabal - mkdir D:\cabal - mklink /J C:\cabal D:\cabal - - - name: Install GHC via ghcup (Windows) - if: matrix.os == 'windows' - run: | - # Pin ghcup to D:\ghcup — D: is the fast local temp disk on - # GH-hosted Windows runners (~83k IOPS vs. ~4k on C:). Same - # rationale as _build-matrix.yml's Windows install step. - curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \ - | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ - BOOTSTRAP_HASKELL_GHC_VERSION=${{ steps.versions.outputs.ghc }} \ - BOOTSTRAP_HASKELL_INSTALL_NO_STACK=1 \ - GHCUP_INSTALL_BASE_PREFIX=/d \ - sh - source /d/ghcup/env - ghc --numeric-version - cabal --numeric-version - - - name: Install build dependencies (Linux) - if: matrix.os == 'linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential gfortran cmake python3 curl \ - zlib1g-dev liblapack-dev libblas-dev - # Same libquadmath workaround as _build-matrix.yml — needed for - # mumps-hs's link step (gfortran archives reference quadmath). - LIBDIR=/usr/lib/$(gcc -print-multiarch) - [[ -d "$LIBDIR" ]] || LIBDIR=/usr/lib - if [[ ! -e "$LIBDIR/libquadmath.so" ]]; then - if [[ -e "$LIBDIR/libquadmath.so.0" ]]; then - sudo ln -s libquadmath.so.0 "$LIBDIR/libquadmath.so" - else - echo 'void __libquadmath_stub(void) {}' \ - | sudo gcc -shared -x c -o "$LIBDIR/libquadmath.so" - - fi - fi - - - name: Install build dependencies (macOS) - if: matrix.os == 'macos' - run: brew install gcc openblas - - - name: Install GHC via ghcup (Linux/macOS) - if: matrix.os == 'linux' || matrix.os == 'macos' - run: | - # Match the bootstrap pattern used by _build-matrix.yml so the - # cabal-install version + GHC layout are identical to what normal - # CI uses — keeps the prebuilt store binary-compatible. - rm -rf "$HOME/.ghcup" - export GHCUP_INSTALL_BASE_PREFIX="$HOME" - curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \ - | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ - BOOTSTRAP_HASKELL_GHC_VERSION=${{ steps.versions.outputs.ghc }} \ - BOOTSTRAP_HASKELL_INSTALL_NO_STACK=1 \ - BOOTSTRAP_HASKELL_ADJUST_BASHRC=no \ - GHCUP_INSTALL_BASE_PREFIX="$HOME" \ - sh - echo "$HOME/.ghcup/bin" >> "$GITHUB_PATH" - # Pin CABAL_DIR=~/.cabal so the legacy paths win regardless of - # cabal version or runner-image XDG defaults — same reason - # _build-matrix.yml does this. - echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" - "$HOME/.ghcup/bin/ghc" --numeric-version - "$HOME/.ghcup/bin/cabal" --numeric-version + matrix-os: ${{ matrix.os }} + matrix-name: ${{ matrix.name }} + ghc-version: ${{ steps.versions.outputs.ghc }} + # Prebuild workflows run on manual dispatch only; no UPX (we + # ship the cabal store, not a binary) and no GHCup cache (the + # ~10-min savings don't matter when we run this maybe once + # per cabal-deps bump). - name: Download prebuilt MUMPS # Force git-bash on Windows instead of the workflow-level msys2 default. @@ -241,49 +158,22 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Compute hash + release tag - id: tag + - name: Read versions + cabal hash + id: meta + # Hash inputs identical to _build-matrix.yml's cabal-store cache + # key, so content change there always implies a new prebuilt. + shell: bash run: | source versions.env - # Hash inputs identical to _build-matrix.yml's actions/cache key - # for the cabal store, so a content change there always implies a - # new prebuilt release. - HASH8=$(cat volca.cabal mumps-hs/mumps-hs.cabal cabal.project | sha256sum | cut -c1-8) - REV="${CABAL_PREBUILT_REVISION:-1}" - TAG="cabal-store-prebuilt-ghc${GHC_VERSION}-${HASH8}-r${REV}" - echo "hash=$HASH8" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "ghc=$GHC_VERSION" >> "$GITHUB_OUTPUT" - echo "rev=$REV" >> "$GITHUB_OUTPUT" + echo "rev=${CABAL_PREBUILT_REVISION:-1}" >> "$GITHUB_OUTPUT" + echo "hash=$(cat volca.cabal mumps-hs/mumps-hs.cabal cabal.project | sha256sum | cut -c1-8)" >> "$GITHUB_OUTPUT" - - name: Fail if release already exists - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if gh release view "${{ steps.tag.outputs.tag }}" >/dev/null 2>&1; then - echo "::error::Release ${{ steps.tag.outputs.tag }} already exists. Bump CABAL_PREBUILT_REVISION in versions.env first." - exit 1 - fi - - - name: Download all artefacts - uses: actions/download-artifact@v4 + - uses: ./.github/actions/publish-prebuilt-release with: - path: dist - merge-multiple: true - - - name: Generate SHA256SUMS - run: | - cd dist - sha256sum cabal-store-*.tar.gz > SHA256SUMS - cat SHA256SUMS - - - name: Create release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - cd dist - gh release create "${{ steps.tag.outputs.tag }}" \ - --title "Cabal store prebuilt — GHC ${{ steps.tag.outputs.ghc }}, hash ${{ steps.tag.outputs.hash }} (revision ${{ steps.tag.outputs.rev }})" \ - --notes "Prebuilt cabal store (~/.cabal/store) for use by _build-matrix.yml. Hash ${{ steps.tag.outputs.hash }} = first 8 chars of sha256(volca.cabal + mumps-hs/mumps-hs.cabal + cabal.project)." \ - --prerelease \ - cabal-store-*.tar.gz SHA256SUMS + tag: cabal-store-prebuilt-ghc${{ steps.meta.outputs.ghc }}-${{ steps.meta.outputs.hash }}-r${{ steps.meta.outputs.rev }} + title: Cabal store prebuilt — GHC ${{ steps.meta.outputs.ghc }}, hash ${{ steps.meta.outputs.hash }} (revision ${{ steps.meta.outputs.rev }}) + notes: Prebuilt cabal store (~/.cabal/store) for use by _build-matrix.yml. Hash ${{ steps.meta.outputs.hash }} = first 8 chars of sha256(volca.cabal + mumps-hs/mumps-hs.cabal + cabal.project). + asset-glob: cabal-store-*.tar.gz + bump-hint: CABAL_PREBUILT_REVISION + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/prebuild-mumps.yml b/.github/workflows/prebuild-mumps.yml index b4771f5..7020904 100644 --- a/.github/workflows/prebuild-mumps.yml +++ b/.github/workflows/prebuild-mumps.yml @@ -49,43 +49,20 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Read pinned versions + - name: Setup build environment (no GHC) + uses: ./.github/actions/setup-haskell-env + with: + matrix-os: ${{ matrix.os }} + matrix-name: ${{ matrix.name }} + # ghc-version='' skips every Cabal/GHC/libquadmath step — we + # only build the MUMPS solver here, never invoke cabal. + + - name: Read pinned MUMPS version id: versions shell: bash run: | source versions.env echo "mumps=$MUMPS_VERSION" >> "$GITHUB_OUTPUT" - echo "rev=${MUMPS_PREBUILT_REVISION:-1}" >> "$GITHUB_OUTPUT" - - - name: Setup MSYS2 (Windows) - if: matrix.os == 'windows' - uses: msys2/setup-msys2@v2 - with: - msystem: UCRT64 - update: true - install: >- - mingw-w64-ucrt-x86_64-gcc - mingw-w64-ucrt-x86_64-gcc-fortran - mingw-w64-ucrt-x86_64-openblas - mingw-w64-ucrt-x86_64-cmake - mingw-w64-ucrt-x86_64-make - make - python - git - curl - tar - - - name: Install build dependencies (Linux) - if: matrix.os == 'linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential gfortran cmake python3 curl \ - zlib1g-dev liblapack-dev libblas-dev - - - name: Install build dependencies (macOS) - if: matrix.os == 'macos' - run: brew install gcc openblas - name: Build MUMPS env: @@ -127,41 +104,17 @@ jobs: - uses: actions/checkout@v4 - name: Read pinned versions - id: versions + id: v run: | source versions.env echo "mumps=$MUMPS_VERSION" >> "$GITHUB_OUTPUT" echo "rev=${MUMPS_PREBUILT_REVISION:-1}" >> "$GITHUB_OUTPUT" - echo "tag=mumps-prebuilt-${MUMPS_VERSION}-r${MUMPS_PREBUILT_REVISION:-1}" >> "$GITHUB_OUTPUT" - - name: Fail if release already exists - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if gh release view "${{ steps.versions.outputs.tag }}" >/dev/null 2>&1; then - echo "::error::Release ${{ steps.versions.outputs.tag }} already exists. Bump MUMPS_PREBUILT_REVISION in versions.env first." - exit 1 - fi - - - name: Download all artefacts - uses: actions/download-artifact@v4 + - uses: ./.github/actions/publish-prebuilt-release with: - path: dist - merge-multiple: true - - - name: Generate SHA256SUMS - run: | - cd dist - sha256sum mumps-prebuilt-*.tar.gz > SHA256SUMS - cat SHA256SUMS - - - name: Create release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - cd dist - gh release create "${{ steps.versions.outputs.tag }}" \ - --title "MUMPS ${{ steps.versions.outputs.mumps }} prebuilt (revision ${{ steps.versions.outputs.rev }})" \ - --notes "Prebuilt MUMPS_SEQ ${{ steps.versions.outputs.mumps }} (PORD ordering, double precision, static archives) for use by _build-matrix.yml. Source: build-mumps.sh." \ - --prerelease \ - mumps-prebuilt-*.tar.gz SHA256SUMS + tag: mumps-prebuilt-${{ steps.v.outputs.mumps }}-r${{ steps.v.outputs.rev }} + title: MUMPS ${{ steps.v.outputs.mumps }} prebuilt (revision ${{ steps.v.outputs.rev }}) + notes: "Prebuilt MUMPS_SEQ ${{ steps.v.outputs.mumps }} (PORD ordering, double precision, static archives) for use by _build-matrix.yml. Source: build-mumps.sh." + asset-glob: mumps-prebuilt-*.tar.gz + bump-hint: MUMPS_PREBUILT_REVISION + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pyvolca.yml b/.github/workflows/pyvolca.yml index 571d6be..55093d9 100644 --- a/.github/workflows/pyvolca.yml +++ b/.github/workflows/pyvolca.yml @@ -11,6 +11,12 @@ concurrency: group: pyvolca-${{ github.head_ref || github.ref }} cancel-in-progress: true +# actions: read is required so gh-CLI can list and download artefacts +# from main's latest build.yml run (used to wire up test_drift.py). +permissions: + contents: read + actions: read + jobs: test: name: Test pyvolca @@ -30,12 +36,46 @@ jobs: - name: Install pyvolca with dev extras run: pip install -e .[dev] + - name: Download engine binary from main's latest build + # Pull the linux-amd64 engine artefact from the most recent + # successful build.yml run on main. test_drift.py's live_spec + # fixture finds it under /dist-newstyle/build/.../volca and + # invokes `volca dump-openapi` to compare against the in-tree + # wrapper list. If no such run exists yet (fresh fork, never + # merged to main), the fixture returns None and the drift tests + # skip themselves — this step exits 0 so the PR is not blocked. + working-directory: ${{ github.workspace }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + RUN_ID=$(gh run list \ + --workflow build.yml \ + --branch main \ + --status success \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty') + if [[ -z "$RUN_ID" ]]; then + echo "::warning::No successful build.yml run on main yet — drift tests will skip themselves." + exit 0 + fi + echo "Downloading engine artefact from build.yml run $RUN_ID." + # actions/upload-artifact@v4 strips the longest common parent + # path, so the engine binary lands at /ghc-*/volca-*/... + # rather than the dist-newstyle/build//... that + # conftest.py's live_spec fixture rglobs for. Recreate the + # parent prefix so the fixture finds the binary. + mkdir -p dist-newstyle/build + gh run download "$RUN_ID" --name volca-linux-amd64 --dir dist-newstyle/build + find dist-newstyle -name volca -type f -exec chmod +x {} + + - name: Run tests - # test_drift.py compares the in-tree wrappers against a freshly - # dumped OpenAPI spec from the volca binary, so it needs a built - # engine. Skipped here; covered locally and (eventually) in a - # follow-up that downloads the linux-amd64 artifact from build.yml. - run: pytest -v --ignore=tests/test_drift.py + # test_drift.py uses the live_spec fixture which auto-skips when + # no engine binary is found under dist-newstyle/. So the previous + # step's best-effort download determines whether drift runs or + # skips, with no need for an --ignore here. + run: pytest -v - name: Verify gen_api_md.py runs cleanly run: python scripts/gen_api_md.py > /dev/null