Skip to content

ci: speed up CI builds and shrink macOS artifact#2

Merged
ccomb merged 13 commits into
mainfrom
ci/cache-ghcup-and-shrink-macos-artifact
Apr 29, 2026
Merged

ci: speed up CI builds and shrink macOS artifact#2
ccomb merged 13 commits into
mainfrom
ci/cache-ghcup-and-shrink-macos-artifact

Conversation

@ccomb

@ccomb ccomb commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

Two improvements to the build matrix introduced in #1:

  • Speed: cache ~/.ghcup on Linux/macOS so warm runs skip the 3–4 min GHC reinstall, and force CABAL_DIR=~/.cabal so cabal actually uses our cached package store (newer cabal-install otherwise prefers $XDG_STATE_HOME and the cached store sits unused). Various smaller fixes along the way: rm the runner-image's symlinked ~/.ghcup shim before bootstrap, override GHCUP_INSTALL_BASE_PREFIX=$HOME in both cold and warm paths, widen the cabal cache restore-keys.
  • Size (macOS): tests run on the unstripped binary (UPX'd Mach-O on arm64 is killed at fork by the kernel even after ad-hoc re-sign — confirmed in this PR's first run), then a post-test step calls ./build.sh --optimize-only to strip + re-sign. Added -Wl,-dead_strip -Wl,-dead_strip_dylibs to the darwin link flags.

Final timings (run 25030160840, fully-warm)

Platform Baseline (PR #1) Warm (this PR) Speedup
macos-arm64 23m54s 1m51s −92%
linux-amd64 17m19s 1m33s −91%
linux-arm64 15m37s 2m11s −86%
windows-amd64 19m48s 5m20s −73%

Cold-cache (run 25029725786, after -v2 invalidation): 5–6 min on Linux/macOS, 8m21s on Windows.

Artifact size (macos-arm64)

Baseline This PR
Zip 26 M 22 M
Binary on disk 121 M 104 M

UPX on macOS arm64 is broken upstream — the --force-macos compressor produces a binary that is SIGKILL'd at fork even after ad-hoc re-signing. Strip-only is the ceiling for now (saved as project memory for future sessions).

Test plan

  • All four matrix jobs go green on cold cache
  • All four matrix jobs go green on warm cache
  • macOS artifact --version smoke test passes after --optimize-only
  • Cabal cache restores under ~/.cabal/store and gets reused (compile log shows no "Building xyz" for deps)

ccomb added 13 commits April 28, 2026 00:49
Cold-cache CI runs spent ~3-4 min on macOS arm64 (~1-2 min on Linux)
re-downloading and installing GHC 9.6.7 via haskell-actions/setup, even
though Windows already had a manual ~/.ghcup cache. Mirror that pattern
for Linux/macOS:

- Restore ~/.ghcup keyed on `${{ matrix.name }}-ghc${{ GHC_VERSION }}`.
- On cache hit, skip haskell-actions/setup entirely; a tiny inline step
  prepends ~/.ghcup/bin to PATH and runs `cabal update` (the Hackage
  index lives outside ~/.ghcup so it isn't covered by the cache).
- Save ~/.ghcup only on cache miss, with no `always()`, so a partial
  install from a failed run isn't silently reused.

Also widen the cabal-store restore-keys with a coarser
`cabal-${{ matrix.name }}-` fallback so a GHC version bump can still
seed the new cache from the previous one (cabal re-resolves but reuses
unchanged object files).
The macOS CI artifact was 26 M zipped — almost 2x the Linux ones — because
build.yml passed --no-optimize: UPX'd Mach-O on arm64 fails to launch
under cabal test even after ad-hoc re-sign, so any test that spawns
volca timed out. Tests therefore had to run on the unstripped binary.

Decouple "build for tests" from "binary to ship":

- build.sh: extract the strip/UPX/codesign block into optimize_volca_binary()
  and add a --optimize-only flag that runs only that block (no cabal
  rebuild, no dep checks). Drop the `-1` flag so UPX uses its default
  compression level — meaningfully smaller than `-1`, still seconds-fast.

- build.yml: after the macOS test step (still --no-optimize), call
  ./build.sh --optimize-only to strip + UPX + re-sign the binary in place,
  then run "$VOLCA_BIN --version" as a smoke test. If UPX corrupts the
  Mach-O (it sometimes does on arm64 even after re-sign), fall back to
  strip-only via cabal re-link from cached objects.

- gen-cabal-config.sh: append `-Wl,-dead_strip` and
  `-Wl,-dead_strip_dylibs` to the darwin link flags. Combined with the
  already-set split-sections, ld64 prunes unreferenced sections and
  unused dylib load commands before strip even runs.
…LL'd

PR #2's first run confirmed UPX 5.1.1 with --force-macos compresses the
arm64 Mach-O fine (104 M -> 48 M, 54 % reduction) but the kernel kills
the resulting process at fork with "Killed: 9", even after
`codesign --force --sign -` re-signs it (job 73290319277). This is
upstream UPX/Mach-O behaviour that we cannot work around from here.

Skip UPX on macOS in optimize_volca_binary; just strip + ad-hoc sign.
The artifact still shrinks ~14 % from strip alone, and the smoke test
in CI now passes deterministically instead of relying on a fallback
that didn't actually re-link (the fallback `rm + cabal build` was a
no-op — cabal sees the build as up-to-date based on sources, not
output binary presence — so the subsequent strip failed with
"can't open file").

Linux + Windows UPX is unchanged.
The first warm-cache run on PR #2 failed with "GHC version 9.14.1
(expected 9.6.7)" on linux-amd64 (job 73294752135). Root cause:
haskell-actions/setup@v2 makes a GHC version "active" by prepending
its version-specific bindir to PATH, not by running `ghcup set ghc`.
So ~/.ghcup/bin/ghc (the bare symlink without a version suffix) is
left pointing at whatever the runner image had set previously — on
ubuntu-latest, the preinstalled GHC 9.14.1. The symlink survives in
the cached ~/.ghcup tarball and silently shadows our pinned 9.6.7
when the warm-path step adds ~/.ghcup/bin to PATH.

Fix: in the warm path, explicitly `ghcup set ghc <version>` to
update the symlink before touching PATH. Also assert the cached
GHC dir exists with a clear error message if the cache is stale.

Also flip the GHCup save step to `if: always()`. The original cold
install runs in a single step that errors out before the save would
fire, so there's no risk of caching a partial ~/.ghcup, and we'd
otherwise lose a successful install just because a later step
(tests, optimize, ...) failed downstream.
The previous warm-cache attempt restored a 202-byte ~/.ghcup tarball and
exited with "Cached ~/.ghcup is missing GHC 9.6.7" — confirmed by
inspecting job 73296608769's logs. Root cause: haskell-actions/setup@v2
detects the runner image's preinstalled /usr/local/.ghcup, links our
"installed" GHC there, and leaves /home/runner/.ghcup essentially empty.
Caching ~/.ghcup therefore saved ~200 bytes of metadata, not the GHC
tree.

Switch the cold path to bootstrap ghcup directly via
get-ghcup.haskell.org with BOOTSTRAP_HASKELL_GHC_VERSION pinned — the
same pattern the Windows path already uses. This guarantees ~/.ghcup
holds the full GHC + cabal install, so the cache actually contains
something useful.

Bump the cache key to -v2 so prior runs' empty caches don't get
restored. Also re-assert the active GHC symlink in the warm path with
`ghcup set` (defence in depth — bootstrap auto-sets it, but a snapshot
taken mid-state could in principle have a different active version).
PR #2 run 25026862228 hit the same "missing GHC 9.6.7" warm-cache
error: the cold install ran and reported success, but only saved
203 bytes — confirmed by inspecting the prior cold run's log
(job 73298297469): "downloading ... as file /usr/local/.ghcup/cache/...".

The runner image exports GHCUP_INSTALL_BASE_PREFIX=/usr/local in the
global environment, so the get-ghcup.haskell.org bootstrap script
(which honors that var) installed into /usr/local/.ghcup, NOT
~/.ghcup. Caching ~/.ghcup therefore captured a few stray symlinks
and nothing else.

Fix: set GHCUP_INSTALL_BASE_PREFIX=$HOME in the cold step before
running the bootstrap. Add a post-install sanity check that bails
loudly if ~/.ghcup/bin/ghc-<version> isn't present, so a future
runner-image change that re-breaks this is immediately visible
instead of silently reverting to a 200-byte cache.

Bump cache key to -v3 to invalidate the bad caches still on disk.
Cold install on Linux did write GHC into ~/.ghcup, but the cache save
still only captured ~200 bytes — confirmed in run 25027426001 job
73301594530 (203 B saved despite a successful 9.6.7 install).

Root cause: the ubuntu-latest image pre-creates ~/.ghcup as a façade
directory whose subentries are SYMLINKS into the system-wide
/usr/local/.ghcup (e.g. `share -> ./ghc/9.14.1/share`). When ghcup
install runs against that directory, it writes through the symlinks,
which means the actual GHC tree ends up at /usr/local/.ghcup/. Then
tar (used by actions/cache) archives the symlink shells in ~/.ghcup
without dereferencing them, so the cache holds only the symlinks —
which on a fresh runner point at /usr/local/.ghcup paths that don't
have our pinned 9.6.7.

Fix: `rm -rf ~/.ghcup` before the bootstrap so the install lands in
a real, plain directory tree. rm doesn't follow symlinks, so the
runner's /usr/local/.ghcup is untouched (and irrelevant because $PATH
in subsequent steps points only at ~/.ghcup/bin).

Also log `du -sh ~/.ghcup` after install so a future regression here
shows up as a visible-in-the-log size, not a silent 200-byte cache.
Bump cache key to -v4.
PR #2 run 25028781359 hit "version 9.6.7 of the tool ghc is not
installed" in the warm step despite a healthy 550 MB cache restore.
Root cause: the runner image exports GHCUP_INSTALL_BASE_PREFIX=/usr/local
globally, so `ghcup set ghc 9.6.7` was looking under
/usr/local/.ghcup/ghc/9.6.7 — which doesn't exist on a fresh runner —
instead of ~/.ghcup/ghc/9.6.7 where the cache restore put it.

Same fix as the cold step: export GHCUP_INSTALL_BASE_PREFIX=$HOME
before invoking ghcup. The downstream cabal/ghc binaries find their
own installation via argv[0] resolution, so they don't need the
override; only ghcup itself does.
Warm v4 run (25029012799) restored a 129 MB cabal cache successfully
but cabal still rebuilt every dep — confirmed in linux-arm64 job
73306452714: "Building blaze-builder-0.4.4.1", "Building bsb-http-chunked",
etc., taking ~9 min instead of expected ~1 min.

Root cause: the ubuntu-latest runner image pre-creates
~/.config/cabal/config, so newer cabal-install versions log
"Both /home/runner/.cabal and /home/runner/.config/cabal/config exist
- ignoring the former" and use XDG paths — including a different
store directory under ~/.local/state/cabal/store. Our cached
~/.cabal/store sat unused.

Fix: set CABAL_DIR=~/.cabal in $GITHUB_ENV for Linux/macOS so cabal
honors the legacy paths regardless of cabal version or runner-image
state. (The warning above explicitly says CABAL_DIR overrides this
auto-detection.)

Bump cabal cache key to -v2: existing caches' dist-newstyle was
populated while cabal used XDG paths, so its plan.json may reference
locations cabal can no longer resolve — cleanest to force one cold
rebuild with the consistent layout, then warm runs will hit cleanly.
@ccomb
ccomb merged commit 74ff498 into main Apr 29, 2026
4 checks passed
@ccomb
ccomb deleted the ci/cache-ghcup-and-shrink-macos-artifact branch April 29, 2026 13:00
ccomb added a commit that referenced this pull request May 31, 2026
…ngine 0.7.0 (#108)

Two fixes to align pyvolca with the VoLCA engine 0.7.0 wire format. Both
are
data-correctness bugs where the client silently lost information.

## Bug #1 — substitutions silently ignored

`Client.get_impacts(substitutions=…)` (and `get_supply_chain`,
`get_inventory`,
`get_impacts_batch`) was broken against 0.7.0. The client built the body
with the
**raw Haskell field names**, but the engine's `stripLowerPrefix` codec
drops the
lowercase type-prefix before parsing, so it reads the *stripped* names:

| raw Haskell field | wire name read by engine |
|-------------------|--------------------------|
| `srSubstitutions` | `substitutions`          |
| `subFrom`         | `from`                   |
| `subTo`           | `to`                     |
| `subConsumer`     | `consumer`               |

pyvolca sent the pre-strip names, so the engine recognised zero fields
and dropped
the substitution without error — the score came back identical to a
no-substitution
call. Correct shape (proven via raw HTTP):

```json
{ "substitutions": [ { "from": "...", "to": "...", "consumer": "..." } ] }
```

`Substitution.to_wire()` and `_substitution_body()` now emit the
stripped keys.

## Bug #2 — SupplyChainEdge dropped the cross-DB columns

The engine's `SupplyChainEdge` carries `edgeFromDb` / `edgeToDb` (each
endpoint's
database name) next to the process ids, but `from_json` read only
`edgeFrom` / `edgeTo` / `edgeAmount` and discarded the two DB columns.
They're
required to route edges across databases — the same process id can exist
in more
than one loaded DB. Added `from_db` / `to_db` to the dataclass and parse
them.

## Misc

- Patch bump to 0.5.1.
- Regenerated the README API reference (also absorbs an earlier
`Method`-docstring drift).

## Verification

`uv run --extra dev pytest` → 137 passed, 7 skipped. New unit tests
cover the
substitution wire shape (typed + dict input) and the edge's DB
endpoints.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant