Skip to content

Publish CI's Linux opam root for coding agents - #13127

Merged
Simn merged 7 commits into
developmentfrom
claude/friendly-sagan-0z2g7q
Nov 3, 2026
Merged

Simn merged 7 commits into
developmentfrom
claude/friendly-sagan-0z2g7q

Conversation

@Simn

@Simn Simn commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Coding agents usually start in a fresh Ubuntu container with no OCaml. Ubuntu only ships OCaml 4.14, so every session has to build OCaml 5.3.0 and all opam dependencies from source, and agents often lose more time getting opam init, the switch and PATH right. CI's linux-build already builds exactly this opam root, but its actions/cache is only reachable from inside GitHub Actions.

This PR has CI publish the opam root that linux-build builds, and gives agents a script that downloads it.

What's in it

  • main.yml
    • linux-build keeps its actions/cache and inline opam init / opam install steps. The opam root moves from ~/.opam to /opt/opam, because opam roots aren't relocatable and agents need a fixed path.
    • On pushes to development, linux-build tars the root and uploads it as an artifact named opam-root-<arch> (kept for one day).
    • The new publish-opam-root job asks extra/agent-setup.sh for the asset name. If that asset isn't on the rolling opam-root prerelease yet, the job uploads it (about 260 MB with zstd) and deletes the one it replaces, so the release holds one archive per architecture.
    • deploy only downloads the *Binaries artifacts, so it doesn't pull the archives.
  • extra/agent-setup.sh (agents and Copilot only; CI doesn't run it)
    • Installs the apt packages (skip with --no-apt).
    • Downloads the published opam root into /opt/opam and checks that it works. If there's none or it doesn't work, it builds from source with the same steps as linux-build.
    • Adds the opam environment to ~/.bashrc and ~/.profile (skip with --no-profile).
    • Runs make haxe (skip with --no-build).
    • The archive key covers the OCaml version, the OS it's built on (Ubuntu 22.04), the CPU architecture, haxe.opam and a REVISION number. Bump REVISION to publish a fresh archive, e.g. to pick up newer dependency versions.
  • copilot-setup-steps.yml: uses the script instead of its own opam cache, and sets up Neko before the OCaml dependencies (conf-neko needs it).
  • AGENTS.md: tells agents to run the script instead of setting up OCaml by hand.

The archive is built on 22.04 and used on 24.04 by agents and Copilot. That should work since glibc is backwards compatible, and the script falls back to a from-source build if the unpacked toolchain doesn't run.

Measured

Path Time
Script, build from source (arm64 runner, Ubuntu 22.04) 3 min 48 s
Script, build from source + make haxe (4-core Ubuntu 24.04 container) 5–6.5 min
Script, unpack the archive + make haxe (same container) 33–44 s, plus downloading 260 MB

The archive path and the fallback were tested locally with a local archive, and the publish logic with a stub gh. The real publish and download can only be tested after the first push to development.

After merging, the Claude Code cloud environment's setup script can be cd haxe && bash extra/agent-setup.sh.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Va4FXDYunkfcakJ5BFq4hq

extra/agent-setup.sh sets up OCaml 5.3.0 and the compiler's opam
dependencies in /opt/opam and builds haxe. It downloads a prebuilt opam
root from the agent-ocaml-deps release when one matches haxe.opam and
the OS, and builds from source otherwise. The new Agent OCaml deps
workflow publishes those prebuilt roots for x86_64 and arm64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va4FXDYunkfcakJ5BFq4hq
@Simn

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

I have a question about how all this interacts with our CI because it's not clear to me what's being built how often: am I understanding correctly that when we push development, this checks if our OCaml dependencies changed and in that case rebuilds a tarball? And otherwise it just downloads the one that was previously built?

If that's the case, shouldn't there be an interaction with our ubuntu CI runners? They also need an OCaml environment in order to build haxe, so I'd like to make sure that we're not building and caching things in different places at the same time.

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Almost. Here's exactly what happens:

  • On push to development: the workflow only runs at all if the push touches haxe.opam, extra/agent-setup.sh or the workflow itself (path filter). When it runs, it computes a key from the OCaml version, the OS, the architecture and the contents of haxe.opam. It builds a new archive only if no asset with that key exists yet. Other pushes don't trigger it at all.
  • Downloads: only agents download the archive, when extra/agent-setup.sh runs at session start. CI doesn't use it.
  • One gap: the key doesn't track upstream opam-repository versions. An archive stays as it is until haxe.opam changes or someone runs the workflow by hand with force.

So you're right that it duplicates CI. Right now there are three independent opam builds:

  1. linux-build in main.yml, using an actions/cache of ~/.opam keyed on haxe.opam + libs/.
  2. copilot-setup-steps.yml, with its own cache.
  3. This new workflow.

There's one catch in merging them. linux-build runs on ubuntu-22.04, presumably so the released binaries work with older glibc, while agents run 24.04. I see two ways to have a single build:

A. Make linux-build the only producer (my suggestion).

  • Point linux-build at OPAMROOT=/opt/opam.
  • Replace its actions/cache with the release asset: download it if one exists for the key, otherwise build it and upload it from development pushes.
  • Agents and Copilot download the same 22.04-built archive. That should work on 24.04, since glibc is backwards compatible and the opam packages' C stubs only link against stable libraries like pcre2 and zlib; luv bundles its own libuv. I'd verify that before switching.
  • agent-ocaml-deps.yml goes away. The key would then name the OS the archive was built on, not the OS of the machine downloading it.

B. Keep the build separate and only share the logic.

  • CI keeps its cache, and agents keep the 24.04 archive.
  • Copilot switches to the script.
  • That's two builds instead of three, with less change to the release pipeline.

I'd go with A. It does touch main.yml, so I'll wait for your call before changing it.


Generated by Claude Code

@Simn

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

I think that makes sense to me, please go ahead and implement it like that so we can review it!

linux-build now gets its OCaml toolchain through extra/agent-setup.sh
instead of an actions/cache of ~/.opam. It downloads the prebuilt opam
root from the opam-root release when one matches, and otherwise builds
it; on pushes to development, the new publish-opam-root job uploads
what was built. Agents and the Copilot setup download the same archive,
so the separate agent-ocaml-deps workflow is gone.

The archive is built on Ubuntu 22.04 (linux-build's runner) and keyed on
the OCaml version, that OS, the architecture, haxe.opam and a manual
revision. The script checks that a downloaded root actually works and
falls back to building from source if it doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va4FXDYunkfcakJ5BFq4hq
@Simn Simn changed the title Add a fast setup path for building the compiler in agent containers Share one prebuilt OCaml setup between Linux CI and agents Nov 3, 2026

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Done in ba1ee89. I've updated the description to match. In short:

  • linux-build gets its OCaml setup from extra/agent-setup.sh (with OPAMROOT=/opt/opam), replacing the actions/cache of ~/.opam. It downloads the published opam root if one matches the key, and builds it otherwise.
  • Publishing: when a push to development had to build it, the new publish-opam-root job uploads it to the opam-root prerelease.
  • Copilot and agents download that same 22.04-built archive. agent-ocaml-deps.yml is gone.
  • Safety check: the script runs ocamlopt after unpacking and falls back to building from source if it doesn't work.

The linux-build run on this push goes through the from-source path on 22.04. Downloading from the release can only be seen once a push to development has published an archive.

Two trade-offs worth looking at:

  • Branches that change haxe.opam no longer get a per-branch cache, so they rebuild the opam root on each run until the change lands.
  • deploy downloads every artifact, so on runs that build an archive it also pulls the ~260 MB opam root and ignores it.

Generated by Claude Code

conf-neko needs the neko binary when the opam root is built from source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va4FXDYunkfcakJ5BFq4hq
@Simn

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author
  • Branches that change haxe.opam no longer get a per-branch cache, so they rebuild the opam root on each run until the change lands.

This should be a rare occurrence so I think it's fine.

  • deploy downloads every artifact, so on runs that build an archive it also pulls the ~260 MB opam root and ignores it.

I have no intuition for how much 260 MB is compared to what it's already doing. Any ideas? And would there be a straightforward way to avoid this?

The deploy job downloaded every artifact of the run, which on runs that
rebuild the opam root includes two ~260 MB archives it doesn't use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va4FXDYunkfcakJ5BFq4hq

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Sizes: deploy uses four artifacts: linuxBinaries, linuxArm64Binaries, macBinaries and win64Binaries. In a recent development run, the Linux binary artifacts were 11–13 MB each and the per-arch Mac ones about 21 MB each. So deploy normally pulls something like 50–100 MB in total. The two opam roots (x86_64 and arm64) would add roughly 500 MB on top. That only happens on the rare development pushes that rebuild them, but it's wasteful.

Avoiding it: deploy now only downloads artifacts matching *Binaries (latest commit). Each one still lands in its own folder named after the artifact, so the rclone paths don't change. As a side effect, it also no longer downloads xmldoc, which it never used.

Status: the arm64 linux-build on the previous commit has already gone through the new path on 22.04. It built the opam root from source via the script in 3m48s, then built Haxe. The Copilot setup failure was a step-order problem: Neko was set up after the OCaml dependencies, which never mattered while its cache was warm. That's fixed in 1b12ae0.


Generated by Claude Code

@tobil4sk

tobil4sk commented Nov 3, 2026

Copy link
Copy Markdown
Member

This seems really overly complicated, why can't the agent setup just reuse the existing linux cache?

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

@tobil4sk Fair question. The short answer is that the actions/cache storage is only reachable from inside a GitHub Actions job. Nothing outside Actions can download it, even for a public repo. So it depends on where the agent runs:

  • Copilot's agent runs inside Actions, so it could use the cache. That's what copilot-setup-steps.yml already did, and for Copilot alone we wouldn't need any of this.
  • Claude Code cloud sessions (and other agents, or people's own containers) run outside Actions. They can't see the cache, so without a published copy they build OCaml and the opam dependencies from source every session: 4–7 minutes, plus agents often fumbling the opam setup.

So to share CI's build with them, it has to be published somewhere downloadable without a token. Workflow artifacts need an authenticated API call to download; release assets don't. Hence the release.

Two more reasons it can't just be the existing cache, even if it were downloadable:

  • opam roots aren't relocatable, and the cache holds /home/runner/.opam. That's why everything moved to a fixed /opt/opam.
  • The key has to be computable by the agent from the checkout alone. That's why the key lives in the script rather than in hashFiles().

Once something is published anyway, having linux-build download it too, instead of keeping a second cache next to it, was Simn's request above: one build instead of three. In main.yml it comes down to one setup step, two steps to package and upload when it was rebuilt, and the small publish-opam-root job.

If that still seems like too much for what it buys, there are simpler options:

  1. Keep linux-build on its existing actions/cache and drop the release. Agents outside Actions then just run the script, which builds from source (4–7 min per session).
  2. Keep the release, but leave linux-build's cache untouched and only add the package-and-publish steps on development. That's less change to CI, but it means two copies again.

Happy to go either way. Simn, your call too since you asked for the current shape.


Generated by Claude Code

@Simn

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Simn, your call too since you asked for the current shape.

Why thank you that I get a say in this! x)

Conceptually, I don't find this very complicated: one thing builds the cache and then several other things use it. Whether or not it's the absolute simplest way to go about it is something I can't answer, but I actually find downloading and extracting a tarball much more palpable than whatever is going on in GitHub actions. I was fine with the tighter coupling during the Copilot days, but IMO it's good to not rely so much on a single infrastructure in the first place.

@tobil4sk

tobil4sk commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

The agent setup problem seems like a separate issue from haxe ci, and I would prefer to avoid replacing a simple 3 line step in linux-build with an entire multi-step bash script just so this can be supported. It scatters the build setup more and adds more moving parts.

The actions/cache feature is designed as a cache for builds, but using releases like that seems like a bit of a hack and doesn't provide the same convenience (no automatic cleanup if we exceed space limits).

I think using a platform like docker would be more appropriate for cloning dev environments (maybe there is some more trendy agent-oriented platform these days).

@Simn

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

I don't really care how we solve this, so if there's a convenient Docker solution that would be fine by me. My main concern (other than spending all my Claude tokens on OCaml setup...) is to not duplicate work, so this seemed like the most natural approach.

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

@tobil4sk Both objections are fair: linux-build now depends on a script instead of three readable lines, and a release has no automatic eviction the way actions/cache does.

On Docker: it would be the natural fit if the agent platforms let us pick the image. As far as I can tell from the documentation available to me, Claude Code's cloud environments don't offer a custom base image; you get their standard image plus a setup script. Copilot's setup is a GitHub Actions job on a stock runner. So in both cases the image would have to be pulled and unpacked in the setup step. To get OCaml out of it we'd still be extracting files from an archive, just with a registry, a Dockerfile and image tooling added on top.

A middle ground that keeps CI the way it was and still avoids a second build:

  • linux-build goes back to its actions/cache and the inline opam init / opam install steps. The only change is OPAMROOT: /opt/opam instead of ~/.opam, because agents can only reuse the root at a fixed path.
  • On a push to development with a cache miss, one extra step tars /opt/opam, and publish-opam-root uploads it, named by a key the script can compute from the checkout.
  • That job also deletes older assets, so the release only ever holds the current archive per arch. That's the cleanup actions/cache would otherwise give us.
  • extra/agent-setup.sh is only used by agents and Copilot, and CI never runs it. It just downloads the archive, or builds from source as before when there's none.

So CI's build setup stays in main.yml where it was, the cache stays a real cache, and the opam root is still built once. The only coupling left is "publish what was built". Shall I switch to that?


Generated by Claude Code

@tobil4sk

tobil4sk commented Nov 3, 2026

Copy link
Copy Markdown
Member

linux-build goes back to its actions/cache and the inline opam init / opam install steps. The only change is OPAMROOT: /opt/opam instead of ~/.opam, because agents can only reuse the root at a fixed path.

The middle ground sounds good, it also allows changing the agent parts freely (like the extra/agent-setup.sh) without affecting linux-build.

@Simn

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

That works for me too, let's do it!

Following the review: linux-build goes back to its actions/cache and the
inline opam init/install steps, with the opam root moved to /opt/opam so
that it can be reused elsewhere. On pushes to development it packages
the root when no archive for the current key is published yet, and
publish-opam-root uploads it and deletes the archive it replaces.

extra/agent-setup.sh is now only used by agents and the Copilot setup.
It no longer assumes a switch name, so it works with the root CI builds
with opam init -c, and it never deletes an existing opam root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va4FXDYunkfcakJ5BFq4hq
@Simn

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Could you clarify what the "on a cache miss" part in your design was about and how it relates to the new package-opam step?

@Simn Simn changed the title Share one prebuilt OCaml setup between Linux CI and agents Publish CI's Linux opam root for coding agents Nov 3, 2026

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Switched to the middle ground in 8b633f0 and updated the description:

  • linux-build has its actions/cache and inline opam init / opam install steps back. The differences from development:
    • OPAMROOT: /opt/opam, with one step that creates that directory.
    • The cache key suffix goes from -1 to -2, since the cached path changed.
    • On development pushes only, a step packages the root if no archive for the current key is published yet.
  • publish-opam-root uploads that archive and deletes the one it replaces, so the release holds one archive per architecture.
  • extra/agent-setup.sh is now only used by agents and the Copilot setup. It no longer assumes a switch name, so it works with the root that opam init -c creates in CI.

Tested locally:

  • building from source with the new steps, plus make haxe: 5m15s;
  • a second run recognises the existing root.

This push's CI run is the first one using the cache at /opt/opam.


Generated by Claude Code

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Good catch, the implementation differs from what I wrote there. In the proposal, "cache miss" meant: only package when linux-build had just built the opam root, i.e. steps.cache-opam.outputs.cache-hit != 'true'. What package-opam checks instead is whether the release already has an archive for the current key. That's what actually matters for agents, and the two answers can disagree in both directions.

Cache hit, but nothing published (a cache-miss condition would never publish):

  • The first run on development saves the cache, but publish-opam-root fails, e.g. on a network error or permissions. From then on every run hits the cache, so nothing would ever be packaged again.
  • REVISION gets bumped in the script to republish. That changes the archive name but not the cache key, so the cache still hits.
  • Someone deletes the asset by hand.

Cache miss, but already published (a cache-miss condition would re-upload 260 MB for nothing):

  • GitHub evicts caches unused for 7 days or over the size limit. The next development push rebuilds the root, but an archive for that key is already there.
  • The two keys aren't computed the same way. The cache key hashes haxe.opam and libs/, while the archive name hashes haxe.opam, the OCaml version, the OS and REVISION. A change to libs/ misses the cache but doesn't need a new archive.

So the step asks the release directly: one HTTP HEAD request on each development push. It only tars and uploads when the archive for the current key is missing. Nothing changes on a cache hit or miss; the cache only decides whether the root is restored or rebuilt.


Generated by Claude Code

Comment thread .github/workflows/main.yml Resolved
As suggested in review: linux-build no longer calls extra/agent-setup.sh.
On development pushes it uploads the opam root as opam-root-<arch>, and
publish-opam-root names it with the script, skips it if that asset is
already published, and otherwise uploads it and deletes older ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va4FXDYunkfcakJ5BFq4hq
@Simn
Simn merged commit dcaad27 into development Nov 3, 2026
64 checks passed
@Simn
Simn deleted the claude/friendly-sagan-0z2g7q branch November 3, 2026 12:14

Simn commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Checked this after the merge in a fresh cloud agent container (Ubuntu 24.04, x86_64), at dcaad27: the prebuilt opam root works, and no OCaml build is needed.

I moved the existing /opt/opam aside, removed haxe, ran make clean, then ran extra/agent-setup.sh --no-apt --no-profile:

[agent-setup] downloading https://github.com/HaxeFoundation/haxe/releases/download/opam-root/opam-5.3.0-ubuntu-22.04-x86_64-3d9b23e229ef5c95.tar.zst
[agent-setup] OCaml 5.3.0, dune 3.24.2
[agent-setup] building the compiler
5.0.0-preview.1
[agent-setup] done

real	0m37.366s
  • The from-source step never ran. The published archive matched the cache key, unpacked, and passed the ocamlfind/dune check.
  • The archive is built on 22.04 and works on 24.04.
  • The compiler that was built runs correctly: ./haxe --run Main on a small test program printed the expected output.
  • The whole run took about 37 s, compared with 5–20 min from source. make haxe may have been sped up by dune's cache from an earlier build in the same container, so a truly cold container could take a bit longer to compile Haxe.

The container's own environment setup had already set itself up from the same archive before this check: /opt/opam was owned by uid 1001, CI's runner user, and kept the CI build timestamps.


Generated by Claude Code

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.

3 participants