From fe4670704aa35527c192f32ab1665f1ecb3a808c Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sun, 3 May 2026 17:43:10 +0200 Subject: [PATCH 1/6] ci: extract reusable composite actions for the build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three workflows (_build-matrix.yml, prebuild-cabal-store.yml, prebuild-mumps.yml) were carrying near-identical platform-setup and release-publish logic. Drift was already creeping in (e.g. msys2 `cache: true` set in one file, missing in two others). Extract into three composite actions: * read-versions: source versions.env and emit GHC + MUMPS pins as step outputs. Replaces 4 inline `Read pinned versions` steps. * setup-haskell-env: cross-platform MSYS2 / apt / brew toolchain install, libquadmath workaround, GHCup bootstrap (with optional actions/cache restore on Linux/macOS), C:\cabal -> D:\cabal junction. Driven by `ghc-version`, `install-upx`, `cache-ghcup` inputs so the same action serves both the engine build (UPX + cache) and the prebuild workflows (no UPX, no cache, optionally no GHC at all for the MUMPS-only build). * publish-prebuilt-release: tag preflight + SHA256SUMS + `gh release create` shared by prebuild-mumps and prebuild-cabal-store. Caller strings (title, notes, glob) flow through env vars instead of direct `${{ }}` shell interpolation, removing a quote-escaping foot-gun. Workflows shrink by ~300 lines net; behaviour is preserved (every original comment block kept, every condition mirrored). The cache save half of the GHCup cache stays in _build-matrix.yml because composite actions cannot defer a step until after the caller's downstream work — the action's outputs feed straight into the save. --- .../publish-prebuilt-release/action.yml | 86 ++++++ .github/actions/read-versions/action.yml | 33 +++ .github/actions/setup-haskell-env/action.yml | 260 ++++++++++++++++++ .github/workflows/_build-matrix.yml | 227 ++------------- .github/workflows/prebuild-cabal-store.yml | 166 ++--------- .github/workflows/prebuild-mumps.yml | 83 +----- 6 files changed, 438 insertions(+), 417 deletions(-) create mode 100644 .github/actions/publish-prebuilt-release/action.yml create mode 100644 .github/actions/read-versions/action.yml create mode 100644 .github/actions/setup-haskell-env/action.yml diff --git a/.github/actions/publish-prebuilt-release/action.yml b/.github/actions/publish-prebuilt-release/action.yml new file mode 100644 index 0000000..7ad75d5 --- /dev/null +++ b/.github/actions/publish-prebuilt-release/action.yml @@ -0,0 +1,86 @@ +name: Publish prebuilt prerelease +description: | + Shared publish step for the manual prebuild-* workflows + (prebuild-mumps.yml, prebuild-cabal-store.yml). Performs: + + 1. Tag pre-flight: fail loudly if the GH Release already exists, + with a hint pointing at the env var to bump in versions.env. + 2. Download every per-platform artefact previously uploaded by the + build matrix into ./dist (merge-multiple). + 3. Generate SHA256SUMS over the asset glob. + 4. Create a prerelease GH Release attaching every matched asset + plus SHA256SUMS. + + Requires the calling job to declare `permissions: contents: write` — + composite actions cannot grant their own permissions. + + All caller-controlled strings are passed via the env, not interpolated + directly into shell commands, so titles or notes containing quotes do + not need escaping. + +inputs: + tag: + description: GH Release tag to publish (must not already exist). + required: true + title: + description: Release title. + required: true + notes: + description: Release notes (markdown body). + required: true + asset-glob: + description: Glob (matching files in dist/) for the assets to attach. SHA256SUMS is appended automatically. + required: true + bump-hint: + description: Name of the env var the user should bump in versions.env to retry. Embedded in the duplicate-tag error message. + required: true + github-token: + description: GITHUB_TOKEN with contents:write scope. Pass `${{ secrets.GITHUB_TOKEN }}` from the caller — composite actions cannot read secrets directly. + required: true + +runs: + using: composite + steps: + - name: Fail if release already exists + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + TAG: ${{ inputs.tag }} + BUMP_HINT: ${{ inputs.bump-hint }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "::error::Release $TAG already exists. Bump $BUMP_HINT in versions.env first." + exit 1 + fi + + - name: Download all artefacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Generate SHA256SUMS + shell: bash + env: + ASSET_GLOB: ${{ inputs.asset-glob }} + # ASSET_GLOB intentionally unquoted so the shell expands the glob. + run: | + cd dist + sha256sum $ASSET_GLOB > SHA256SUMS + cat SHA256SUMS + + - name: Create release + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + TAG: ${{ inputs.tag }} + TITLE: ${{ inputs.title }} + NOTES: ${{ inputs.notes }} + ASSET_GLOB: ${{ inputs.asset-glob }} + run: | + cd dist + gh release create "$TAG" \ + --title "$TITLE" \ + --notes "$NOTES" \ + --prerelease \ + $ASSET_GLOB SHA256SUMS diff --git a/.github/actions/read-versions/action.yml b/.github/actions/read-versions/action.yml new file mode 100644 index 0000000..6ffa5a0 --- /dev/null +++ b/.github/actions/read-versions/action.yml @@ -0,0 +1,33 @@ +name: Read pinned versions +description: Source versions.env at the repo root and emit each pinned build version as a step output. + +# Single source of truth for the GHC + MUMPS pins consumed by every +# workflow. Every output that has a *_REVISION sibling defaults to "1" +# when the env var is unset, matching the inline `${VAR:-1}` fallbacks +# the workflows used previously. + +outputs: + ghc: + description: GHC compiler version (GHC_VERSION). + value: ${{ steps.read.outputs.ghc }} + mumps: + description: MUMPS solver version (MUMPS_VERSION). + value: ${{ steps.read.outputs.mumps }} + mumps_rev: + description: Prebuilt MUMPS release revision (MUMPS_PREBUILT_REVISION, default 1). + value: ${{ steps.read.outputs.mumps_rev }} + cabal_rev: + description: Prebuilt cabal-store release revision (CABAL_PREBUILT_REVISION, default 1). + value: ${{ steps.read.outputs.cabal_rev }} + +runs: + using: composite + steps: + - id: read + shell: bash + 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" + echo "cabal_rev=${CABAL_PREBUILT_REVISION:-1}" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/setup-haskell-env/action.yml b/.github/actions/setup-haskell-env/action.yml new file mode 100644 index 0000000..4dbbcc5 --- /dev/null +++ b/.github/actions/setup-haskell-env/action.yml @@ -0,0 +1,260 @@ +name: Setup Haskell build environment +description: | + Cross-platform setup for the volca Haskell build: + * Windows: MSYS2 (UCRT64) with toolchain + optional UPX, C:\cabal + junction onto D:\, GHCup install onto D:\ghcup. + * Linux: apt build-essential / gfortran / openblas / lapack / cmake, + libquadmath workaround, optional upx-ucl, GHCup install (with + optional actions/cache restore). + * macOS: brew gcc + openblas + optional upx, GHCup install (with + optional actions/cache restore). + + Set ghc-version='' to skip every Cabal/GHC-related step (used by the + MUMPS-only prebuild that compiles the solver but never invokes cabal). + + When cache-ghcup='true', the GHCup restore here pairs with a Save step + that the caller MUST add itself, after the build completes: + + - if: always() && (matrix.os == 'linux' || matrix.os == 'macos') && steps.cache-ghcup-unix.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.ghcup + key: ${{ steps.setup-haskell-env.outputs.ghcup-cache-key }} + + Composite actions cannot defer a step until after the caller's + downstream work, so the save half of the cache is the caller's + responsibility — just like with vanilla actions/cache/restore@v4. + +inputs: + matrix-os: + description: Matrix OS — one of "linux", "windows", "macos". + required: true + matrix-name: + description: Matrix name (e.g. "linux-amd64"). Used as part of the GHCup cache key when cache-ghcup='true'. + required: true + ghc-version: + description: GHC version to install (e.g. "9.6.7"). Empty string skips every Cabal/GHC step (use for MUMPS-only builds). + required: false + default: '' + install-upx: + description: | + Install UPX (binary compression). Pulls mingw-w64-ucrt-x86_64-upx + mingw-w64-ucrt-x86_64-python on Windows, + upx-ucl on Linux, brew upx on macOS. Only the engine release build needs this. + required: false + default: 'false' + cache-ghcup: + description: Restore the GHCup install from actions/cache on Linux/macOS. Caller adds the matching save step (see description). + required: false + default: 'false' + +outputs: + ghcup-cache-hit: + description: Output of actions/cache/restore@v4 — empty string when cache-ghcup='false' or on Windows. + value: ${{ steps.cache-ghcup-unix.outputs.cache-hit }} + ghcup-cache-key: + description: Primary cache key used by the restore step. Pass to actions/cache/save@v4 in the caller's save step. + value: ${{ steps.cache-ghcup-unix.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 + # Note: install lists must be space-separated on a single line for + # msys2-action to parse correctly. The ${{ }} expressions resolve + # to the package name or empty string before the YAML scalar is + # tokenised, so install-upx='false' degenerates to a clean list. + 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 + + # 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: 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: install fresh every run, into D:\ghcup. + # + # 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. + # + # 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). Pinning ghcup to D:\ghcup via GHCUP_INSTALL_BASE_PREFIX + # puts every subsequent ghc/cabal read on the fast disk. D: is + # ephemeral (lost on VM redeploy) but GH runners are ephemeral too. + - name: Install GHC via ghcup (Windows MSYS2) + 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 + # 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: 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 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. + # Skipped when ghc-version is empty: the MUMPS-only prebuild + # builds libdmumps_seq.a without ever invoking the link step + # that pulls in 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" + 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 + 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 install (Linux/macOS) + id: cache-ghcup-unix + 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 + + # 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. + - name: Pin cabal store dir (Linux/macOS) + if: (inputs.matrix-os == 'linux' || inputs.matrix-os == 'macos') && inputs.ghc-version != '' + shell: bash + run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" + + # 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. 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. + - name: Install GHC + Cabal via ghcup (Linux/macOS, cold) + if: (inputs.matrix-os == 'linux' || inputs.matrix-os == 'macos') && inputs.ghc-version != '' && steps.cache-ghcup-unix.outputs.cache-hit != 'true' + shell: bash + 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=${{ inputs.ghc-version }} \ + 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-${{ inputs.ghc-version }}" ]]; 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: (inputs.matrix-os == 'linux' || inputs.matrix-os == 'macos') && inputs.ghc-version != '' && 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. Match the cold step's override. + export GHCUP_INSTALL_BASE_PREFIX="$HOME" + GHC_VER="${{ inputs.ghc-version }}" + 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 so cabal solve has a current view. + "$HOME/.ghcup/bin/cabal" update diff --git a/.github/workflows/_build-matrix.yml b/.github/workflows/_build-matrix.yml index 85faa0d..ea37584 100644 --- a/.github/workflows/_build-matrix.yml +++ b/.github/workflows/_build-matrix.yml @@ -50,11 +50,7 @@ jobs: - name: Read pinned versions id: versions - shell: bash - run: | - source versions.env - echo "ghc=$GHC_VERSION" >> "$GITHUB_OUTPUT" - echo "mumps=$MUMPS_VERSION" >> "$GITHUB_OUTPUT" + uses: ./.github/actions/read-versions - name: Read engine version from cabal id: cabal-version @@ -63,204 +59,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 +286,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..dcf5ddd 100644 --- a/.github/workflows/prebuild-cabal-store.yml +++ b/.github/workflows/prebuild-cabal-store.yml @@ -56,104 +56,18 @@ jobs: - name: Read pinned versions id: versions - shell: bash - 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" + uses: ./.github/actions/read-versions - - 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 +155,23 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Compute hash + release tag - id: tag - 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" + - name: Read pinned versions + id: versions + uses: ./.github/actions/read-versions - - 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: Compute cabal store hash + id: hash + # 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. + shell: bash + run: echo "hash=$(cat volca.cabal mumps-hs/mumps-hs.cabal cabal.project | sha256sum | cut -c1-8)" >> "$GITHUB_OUTPUT" - - 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.versions.outputs.ghc }}-${{ steps.hash.outputs.hash }}-r${{ steps.versions.outputs.cabal_rev }} + title: Cabal store prebuilt — GHC ${{ steps.versions.outputs.ghc }}, hash ${{ steps.hash.outputs.hash }} (revision ${{ steps.versions.outputs.cabal_rev }}) + notes: Prebuilt cabal store (~/.cabal/store) for use by _build-matrix.yml. Hash ${{ steps.hash.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..788ca29 100644 --- a/.github/workflows/prebuild-mumps.yml +++ b/.github/workflows/prebuild-mumps.yml @@ -51,41 +51,15 @@ jobs: - name: Read pinned versions id: versions - shell: bash - run: | - source versions.env - echo "mumps=$MUMPS_VERSION" >> "$GITHUB_OUTPUT" - echo "rev=${MUMPS_PREBUILT_REVISION:-1}" >> "$GITHUB_OUTPUT" + uses: ./.github/actions/read-versions - - name: Setup MSYS2 (Windows) - if: matrix.os == 'windows' - uses: msys2/setup-msys2@v2 + - name: Setup build environment (no GHC) + 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 - - - 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 + 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: Build MUMPS env: @@ -128,40 +102,13 @@ jobs: - name: Read pinned versions id: versions - 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" + uses: ./.github/actions/read-versions - - 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.versions.outputs.mumps }}-r${{ steps.versions.outputs.mumps_rev }} + title: MUMPS ${{ steps.versions.outputs.mumps }} prebuilt (revision ${{ steps.versions.outputs.mumps_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." + asset-glob: mumps-prebuilt-*.tar.gz + bump-hint: MUMPS_PREBUILT_REVISION + github-token: ${{ secrets.GITHUB_TOKEN }} From 778680b4ccf011c595996784b986201f29b3d888 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sun, 3 May 2026 17:43:31 +0200 Subject: [PATCH 2/6] ci: harden build matrix + run pyvolca drift test against main's engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated hygiene improvements that don't warrant separate PRs: _build-matrix.yml: * Add `permissions: contents: read` at the workflow level. The reusable matrix only checks out the repo and downloads from public release assets; publishing happens in callers under their own elevated scope, so dropping the default token to read-only eliminates a write capability the matrix never uses. * Set `timeout-minutes: 60` at the job level. Windows already has a 25-min internal timeout for hung tests; Linux/macOS had none and would burn the full 360-min default on a wedged process. 60 min covers a worst-case cold path (MSYS2 + GHCup + cold cabal compile + tests ≈ 50 min) with ~10 min headroom. pyvolca.yml: * Wire up tests/test_drift.py, which had been skipped with a TODO comment since it needs a built engine. Pull the linux-amd64 artefact from main's most recent successful build.yml run via `gh run download`, drop it under dist-newstyle/ where the live_spec fixture finds it, and let pytest run the drift checks against that engine. If no main build exists yet the download step exits 0 with a warning, the fixture returns None, and the drift tests skip themselves — the PR is never blocked. * Add `permissions: actions: read` so gh-CLI can list/download artefacts from another workflow's run. --- .github/workflows/_build-matrix.yml | 11 ++++++ .github/workflows/pyvolca.yml | 54 ++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/.github/workflows/_build-matrix.yml b/.github/workflows/_build-matrix.yml index ea37584..46781b2 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: diff --git a/.github/workflows/pyvolca.yml b/.github/workflows/pyvolca.yml index 571d6be..cdfa901 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,50 @@ 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." + gh run download "$RUN_ID" --name volca-linux-amd64 --dir engine-dl + # The artefact preserves dist-newstyle/build/.../volca path + # structure from the upload glob in _build-matrix.yml. + if [[ ! -d engine-dl/dist-newstyle ]]; then + echo "::warning::Downloaded artefact has unexpected layout — listing for diagnostics:" + find engine-dl -maxdepth 4 -print + exit 0 + fi + mv engine-dl/dist-newstyle . + rm -rf engine-dl + # Restore +x — gh run download does not preserve POSIX mode. + 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 From fd8f8199c1def1b85c9750cfbeb15344feefbf72 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sun, 3 May 2026 17:51:59 +0200 Subject: [PATCH 3/6] ci: trim composite-action bloat + fix CI breakage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous PR commits added composite actions but the net line count went up by ~76 — too much documentation and structure for the value. Also: setup-haskell-env's description block contained a literal \${{ steps.setup-haskell-env.outputs.ghcup-cache-key }} as a code example, which GitHub tried to evaluate inside the action metadata context (where `steps` doesn't exist) and refused to load — that's what failed every job in the previous run. Changes: * Drop .github/actions/read-versions: 33 lines + 4 wire-ups to replace ~25 lines of inline `source versions.env`. Net loss for a marginal DRY win. The 5 call sites get back their inline reads (the cabal-store publish job collapses two reads into one). * setup-haskell-env: drop the 27-line description block (with the template-in-metadata bug), tighten input descriptions to one line, merge the cold/warm GHCup steps into one shell that branches on whether the cache was hit. Fewer steps = less GH Actions overhead on the warm path. From 260 to 149 lines. * publish-prebuilt-release: same descriptor-trim treatment plus inline two near-identical steps. From 86 to 62 lines. Net workflow+action total drops from 1336 to 1177 lines (-159), and versus origin/main the whole refactor is now -86 lines net (with the new pyvolca drift-test feature included). --- .../publish-prebuilt-release/action.yml | 60 ++--- .github/actions/read-versions/action.yml | 33 --- .github/actions/setup-haskell-env/action.yml | 243 +++++------------- .github/workflows/_build-matrix.yml | 6 +- .github/workflows/prebuild-cabal-store.yml | 30 ++- .github/workflows/prebuild-mumps.yml | 21 +- 6 files changed, 115 insertions(+), 278 deletions(-) delete mode 100644 .github/actions/read-versions/action.yml diff --git a/.github/actions/publish-prebuilt-release/action.yml b/.github/actions/publish-prebuilt-release/action.yml index 7ad75d5..f4e21d4 100644 --- a/.github/actions/publish-prebuilt-release/action.yml +++ b/.github/actions/publish-prebuilt-release/action.yml @@ -1,42 +1,28 @@ name: Publish prebuilt prerelease -description: | - Shared publish step for the manual prebuild-* workflows - (prebuild-mumps.yml, prebuild-cabal-store.yml). Performs: - - 1. Tag pre-flight: fail loudly if the GH Release already exists, - with a hint pointing at the env var to bump in versions.env. - 2. Download every per-platform artefact previously uploaded by the - build matrix into ./dist (merge-multiple). - 3. Generate SHA256SUMS over the asset glob. - 4. Create a prerelease GH Release attaching every matched asset - plus SHA256SUMS. - - Requires the calling job to declare `permissions: contents: write` — - composite actions cannot grant their own permissions. - - All caller-controlled strings are passed via the env, not interpolated - directly into shell commands, so titles or notes containing quotes do - not need escaping. +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: - description: GH Release tag to publish (must not already exist). required: true + description: GH Release tag (must not already exist). title: - description: Release title. required: true + description: Release title. notes: - description: Release notes (markdown body). required: true + description: Release notes (markdown body). asset-glob: - description: Glob (matching files in dist/) for the assets to attach. SHA256SUMS is appended automatically. required: true + description: Glob (matching files in dist/) for assets to attach. SHA256SUMS appended automatically. bump-hint: - description: Name of the env var the user should bump in versions.env to retry. Embedded in the duplicate-tag error message. required: true + description: Env var name to mention in the duplicate-tag error. github-token: - description: GITHUB_TOKEN with contents:write scope. Pass `${{ secrets.GITHUB_TOKEN }}` from the caller — composite actions cannot read secrets directly. required: true + description: Pass secrets.GITHUB_TOKEN from the caller — composite actions cannot read secrets directly. runs: using: composite @@ -46,31 +32,23 @@ runs: env: GH_TOKEN: ${{ inputs.github-token }} TAG: ${{ inputs.tag }} - BUMP_HINT: ${{ inputs.bump-hint }} + BUMP: ${{ inputs.bump-hint }} run: | if gh release view "$TAG" >/dev/null 2>&1; then - echo "::error::Release $TAG already exists. Bump $BUMP_HINT in versions.env first." + echo "::error::Release $TAG already exists. Bump $BUMP in versions.env first." exit 1 fi - - name: Download all artefacts - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - - name: Generate SHA256SUMS - shell: bash - env: - ASSET_GLOB: ${{ inputs.asset-glob }} - # ASSET_GLOB intentionally unquoted so the shell expands the glob. - run: | - cd dist - sha256sum $ASSET_GLOB > SHA256SUMS - cat SHA256SUMS - - 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 }} @@ -79,8 +57,6 @@ runs: ASSET_GLOB: ${{ inputs.asset-glob }} run: | cd dist - gh release create "$TAG" \ - --title "$TITLE" \ - --notes "$NOTES" \ - --prerelease \ + sha256sum $ASSET_GLOB > SHA256SUMS + gh release create "$TAG" --title "$TITLE" --notes "$NOTES" --prerelease \ $ASSET_GLOB SHA256SUMS diff --git a/.github/actions/read-versions/action.yml b/.github/actions/read-versions/action.yml deleted file mode 100644 index 6ffa5a0..0000000 --- a/.github/actions/read-versions/action.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Read pinned versions -description: Source versions.env at the repo root and emit each pinned build version as a step output. - -# Single source of truth for the GHC + MUMPS pins consumed by every -# workflow. Every output that has a *_REVISION sibling defaults to "1" -# when the env var is unset, matching the inline `${VAR:-1}` fallbacks -# the workflows used previously. - -outputs: - ghc: - description: GHC compiler version (GHC_VERSION). - value: ${{ steps.read.outputs.ghc }} - mumps: - description: MUMPS solver version (MUMPS_VERSION). - value: ${{ steps.read.outputs.mumps }} - mumps_rev: - description: Prebuilt MUMPS release revision (MUMPS_PREBUILT_REVISION, default 1). - value: ${{ steps.read.outputs.mumps_rev }} - cabal_rev: - description: Prebuilt cabal-store release revision (CABAL_PREBUILT_REVISION, default 1). - value: ${{ steps.read.outputs.cabal_rev }} - -runs: - using: composite - steps: - - id: read - shell: bash - 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" - echo "cabal_rev=${CABAL_PREBUILT_REVISION:-1}" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/setup-haskell-env/action.yml b/.github/actions/setup-haskell-env/action.yml index 4dbbcc5..97cf2b3 100644 --- a/.github/actions/setup-haskell-env/action.yml +++ b/.github/actions/setup-haskell-env/action.yml @@ -1,59 +1,16 @@ name: Setup Haskell build environment -description: | - Cross-platform setup for the volca Haskell build: - * Windows: MSYS2 (UCRT64) with toolchain + optional UPX, C:\cabal - junction onto D:\, GHCup install onto D:\ghcup. - * Linux: apt build-essential / gfortran / openblas / lapack / cmake, - libquadmath workaround, optional upx-ucl, GHCup install (with - optional actions/cache restore). - * macOS: brew gcc + openblas + optional upx, GHCup install (with - optional actions/cache restore). - - Set ghc-version='' to skip every Cabal/GHC-related step (used by the - MUMPS-only prebuild that compiles the solver but never invokes cabal). - - When cache-ghcup='true', the GHCup restore here pairs with a Save step - that the caller MUST add itself, after the build completes: - - - if: always() && (matrix.os == 'linux' || matrix.os == 'macos') && steps.cache-ghcup-unix.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: ~/.ghcup - key: ${{ steps.setup-haskell-env.outputs.ghcup-cache-key }} - - Composite actions cannot defer a step until after the caller's - downstream work, so the save half of the cache is the caller's - responsibility — just like with vanilla actions/cache/restore@v4. +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: - description: Matrix OS — one of "linux", "windows", "macos". - required: true - matrix-name: - description: Matrix name (e.g. "linux-amd64"). Used as part of the GHCup cache key when cache-ghcup='true'. - required: true - ghc-version: - description: GHC version to install (e.g. "9.6.7"). Empty string skips every Cabal/GHC step (use for MUMPS-only builds). - required: false - default: '' - install-upx: - description: | - Install UPX (binary compression). Pulls mingw-w64-ucrt-x86_64-upx + mingw-w64-ucrt-x86_64-python on Windows, - upx-ucl on Linux, brew upx on macOS. Only the engine release build needs this. - required: false - default: 'false' - cache-ghcup: - description: Restore the GHCup install from actions/cache on Linux/macOS. Caller adds the matching save step (see description). - required: false - default: 'false' + 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@v4 — empty string when cache-ghcup='false' or on Windows. - value: ${{ steps.cache-ghcup-unix.outputs.cache-hit }} - ghcup-cache-key: - description: Primary cache key used by the restore step. Pass to actions/cache/save@v4 in the caller's save step. - value: ${{ steps.cache-ghcup-unix.outputs.cache-primary-key }} + 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 @@ -65,10 +22,6 @@ runs: msystem: UCRT64 update: true cache: true - # Note: install lists must be space-separated on a single line for - # msys2-action to parse correctly. The ${{ }} expressions resolve - # to the package name or empty string before the YAML scalar is - # tokenised, so install-upx='false' degenerates to a clean list. install: >- mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-gcc-fortran @@ -77,25 +30,13 @@ runs: 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 + 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) + # 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: | @@ -103,20 +44,10 @@ runs: mkdir D:\cabal mklink /J C:\cabal D:\cabal - # GHCup on Windows: install fresh every run, into D:\ghcup. - # - # 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. - # - # 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). Pinning ghcup to D:\ghcup via GHCUP_INSTALL_BASE_PREFIX - # puts every subsequent ghc/cabal read on the fast disk. D: is - # ephemeral (lost on VM redeploy) but GH runners are ephemeral too. - - name: Install GHC via ghcup (Windows MSYS2) + # 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: | @@ -126,12 +57,10 @@ runs: BOOTSTRAP_HASKELL_INSTALL_NO_STACK=1 \ GHCUP_INSTALL_BASE_PREFIX=/d \ sh - # Subsequent MSYS2 steps need to source this themselves — the - # msys2.CMD wrapper does not propagate GITHUB_PATH. + # 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 + ghc --version && cabal --version && cabal update - name: Install build dependencies (Linux) if: inputs.matrix-os == 'linux' @@ -142,27 +71,22 @@ runs: 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 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. - # Skipped when ghc-version is empty: the MUMPS-only prebuild - # builds libdmumps_seq.a without ever invoking the link step - # that pulls in quadmath. + # 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" - 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 fi @@ -170,91 +94,56 @@ runs: - name: Install build dependencies (macOS) if: inputs.matrix-os == 'macos' shell: bash - run: | - brew install gcc openblas ${{ inputs.install-upx == 'true' && 'upx' || '' }} + run: brew install gcc openblas ${{ inputs.install-upx == 'true' && 'upx' || '' }} - - name: Restore GHCup install (Linux/macOS) - id: cache-ghcup-unix + - 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 - # 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. - - name: Pin cabal store dir (Linux/macOS) + # 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 - run: echo "CABAL_DIR=$HOME/.cabal" >> "$GITHUB_ENV" - - # 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. 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. - - name: Install GHC + Cabal via ghcup (Linux/macOS, cold) - if: (inputs.matrix-os == 'linux' || inputs.matrix-os == 'macos') && inputs.ghc-version != '' && steps.cache-ghcup-unix.outputs.cache-hit != 'true' - shell: bash - 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=${{ inputs.ghc-version }} \ - 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-${{ inputs.ghc-version }}" ]]; 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: (inputs.matrix-os == 'linux' || inputs.matrix-os == 'macos') && inputs.ghc-version != '' && steps.cache-ghcup-unix.outputs.cache-hit == 'true' - shell: bash + env: + GHC_VER: ${{ inputs.ghc-version }} 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. Match the cold step's override. export GHCUP_INSTALL_BASE_PREFIX="$HOME" - GHC_VER="${{ inputs.ghc-version }}" - 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 + # 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. + 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 - # 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 so cabal solve has a current view. + # ~/.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 46781b2..89e14c8 100644 --- a/.github/workflows/_build-matrix.yml +++ b/.github/workflows/_build-matrix.yml @@ -61,7 +61,11 @@ jobs: - name: Read pinned versions id: versions - uses: ./.github/actions/read-versions + shell: bash + run: | + source versions.env + echo "ghc=$GHC_VERSION" >> "$GITHUB_OUTPUT" + echo "mumps=$MUMPS_VERSION" >> "$GITHUB_OUTPUT" - name: Read engine version from cabal id: cabal-version diff --git a/.github/workflows/prebuild-cabal-store.yml b/.github/workflows/prebuild-cabal-store.yml index dcf5ddd..b1ac55b 100644 --- a/.github/workflows/prebuild-cabal-store.yml +++ b/.github/workflows/prebuild-cabal-store.yml @@ -56,7 +56,10 @@ jobs: - name: Read pinned versions id: versions - uses: ./.github/actions/read-versions + shell: bash + run: | + source versions.env + echo "ghc=$GHC_VERSION" >> "$GITHUB_OUTPUT" - name: Setup Haskell build environment uses: ./.github/actions/setup-haskell-env @@ -155,23 +158,22 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Read pinned versions - id: versions - uses: ./.github/actions/read-versions - - - name: Compute cabal store hash - id: hash - # 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. + - 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: echo "hash=$(cat volca.cabal mumps-hs/mumps-hs.cabal cabal.project | sha256sum | cut -c1-8)" >> "$GITHUB_OUTPUT" + run: | + source versions.env + echo "ghc=$GHC_VERSION" >> "$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" - uses: ./.github/actions/publish-prebuilt-release with: - tag: cabal-store-prebuilt-ghc${{ steps.versions.outputs.ghc }}-${{ steps.hash.outputs.hash }}-r${{ steps.versions.outputs.cabal_rev }} - title: Cabal store prebuilt — GHC ${{ steps.versions.outputs.ghc }}, hash ${{ steps.hash.outputs.hash }} (revision ${{ steps.versions.outputs.cabal_rev }}) - notes: Prebuilt cabal store (~/.cabal/store) for use by _build-matrix.yml. Hash ${{ steps.hash.outputs.hash }} = first 8 chars of sha256(volca.cabal + mumps-hs/mumps-hs.cabal + cabal.project). + 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 788ca29..f3dc26f 100644 --- a/.github/workflows/prebuild-mumps.yml +++ b/.github/workflows/prebuild-mumps.yml @@ -49,10 +49,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Read pinned versions - id: versions - uses: ./.github/actions/read-versions - - name: Setup build environment (no GHC) uses: ./.github/actions/setup-haskell-env with: @@ -62,9 +58,9 @@ jobs: # only build the MUMPS solver here, never invoke cabal. - name: Build MUMPS - env: - MUMPS_VERSION: ${{ steps.versions.outputs.mumps }} run: | + source versions.env + export MUMPS_VERSION # Per-platform BLAS hints — mirror what build.sh chooses for the # source-build fallback path so the prebuilt archives are bit-for-bit # what an in-CI source build would produce. @@ -101,14 +97,17 @@ jobs: - uses: actions/checkout@v4 - name: Read pinned versions - id: versions - uses: ./.github/actions/read-versions + id: v + run: | + source versions.env + echo "mumps=$MUMPS_VERSION" >> "$GITHUB_OUTPUT" + echo "rev=${MUMPS_PREBUILT_REVISION:-1}" >> "$GITHUB_OUTPUT" - uses: ./.github/actions/publish-prebuilt-release with: - tag: mumps-prebuilt-${{ steps.versions.outputs.mumps }}-r${{ steps.versions.outputs.mumps_rev }} - title: MUMPS ${{ steps.versions.outputs.mumps }} prebuilt (revision ${{ steps.versions.outputs.mumps_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." + 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 }} From 29a5eacdfc3622a952508e351362443b6bd36070 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sun, 3 May 2026 17:54:20 +0200 Subject: [PATCH 4/6] ci(pyvolca): reconstruct stripped dist-newstyle/build prefix actions/upload-artifact@v4 strips the longest common parent path from the upload glob, so volca-linux-amd64 lands as /ghc-*/volca-*/... rather than the dist-newstyle/build//... that conftest.py's live_spec fixture rglobs for. Drift tests were silently skipping. Recreate the dist-newstyle/build/ prefix and download into it. --- .github/workflows/pyvolca.yml | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/workflows/pyvolca.yml b/.github/workflows/pyvolca.yml index cdfa901..55093d9 100644 --- a/.github/workflows/pyvolca.yml +++ b/.github/workflows/pyvolca.yml @@ -61,17 +61,13 @@ jobs: exit 0 fi echo "Downloading engine artefact from build.yml run $RUN_ID." - gh run download "$RUN_ID" --name volca-linux-amd64 --dir engine-dl - # The artefact preserves dist-newstyle/build/.../volca path - # structure from the upload glob in _build-matrix.yml. - if [[ ! -d engine-dl/dist-newstyle ]]; then - echo "::warning::Downloaded artefact has unexpected layout — listing for diagnostics:" - find engine-dl -maxdepth 4 -print - exit 0 - fi - mv engine-dl/dist-newstyle . - rm -rf engine-dl - # Restore +x — gh run download does not preserve POSIX mode. + # 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 From 9e659ed9671be6789157a8bf8ac8bf27f1c6571b Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sun, 3 May 2026 17:56:44 +0200 Subject: [PATCH 5/6] ci: export CABAL_DIR in-step (not just to GITHUB_ENV) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging the cold/warm GHCup steps into one regressed cabal update: the previous structure had `Pin CABAL_DIR` as its own step that wrote to GITHUB_ENV and was therefore visible to the next step's `cabal update`. After merging, `cabal update` runs in the SAME step that writes GITHUB_ENV — but $GITHUB_ENV only takes effect for subsequent steps, so cabal update wrote the Hackage index under XDG_STATE_HOME (default for cabal-install >=3.10) while the build step (which DID see the new CABAL_DIR=~/.cabal) found no index and failed with "unknown package: vector (dependency of mumps-hs)". Add `export CABAL_DIR` alongside the GITHUB_ENV write so cabal in the same step uses the right path too. --- .github/actions/setup-haskell-env/action.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/actions/setup-haskell-env/action.yml b/.github/actions/setup-haskell-env/action.yml index 97cf2b3..6444b6a 100644 --- a/.github/actions/setup-haskell-env/action.yml +++ b/.github/actions/setup-haskell-env/action.yml @@ -122,6 +122,10 @@ runs: # 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 From 14b4231ed043dcef513f7b7c9c1315dbd939854a Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sun, 3 May 2026 18:33:32 +0200 Subject: [PATCH 6/6] ci(prebuild-mumps): hoist MUMPS_VERSION into an explicit env: block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the read-step + env: pattern previously used here. `source versions.env; export MUMPS_VERSION` works but couples the dependency to whoever happens to write the first two lines of the run-block — the explicit env: makes the dependency visible at the step header. --- .github/workflows/prebuild-mumps.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/prebuild-mumps.yml b/.github/workflows/prebuild-mumps.yml index f3dc26f..7020904 100644 --- a/.github/workflows/prebuild-mumps.yml +++ b/.github/workflows/prebuild-mumps.yml @@ -57,10 +57,17 @@ jobs: # ghc-version='' skips every Cabal/GHC/libquadmath step — we # only build the MUMPS solver here, never invoke cabal. - - name: Build MUMPS + - name: Read pinned MUMPS version + id: versions + shell: bash run: | source versions.env - export MUMPS_VERSION + echo "mumps=$MUMPS_VERSION" >> "$GITHUB_OUTPUT" + + - name: Build MUMPS + env: + MUMPS_VERSION: ${{ steps.versions.outputs.mumps }} + run: | # Per-platform BLAS hints — mirror what build.sh chooses for the # source-build fallback path so the prebuilt archives are bit-for-bit # what an in-CI source build would produce.