From c9a9393c0e96654dfa253ca84064b6b122245254 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 28 Aug 2026 17:55:28 +0100 Subject: [PATCH 1/7] Cache nix-built binaries directly --- .github/workflows/ci.yml | 56 +++++++++++++++++++++++++++----- bin/build-native-modules | 69 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 111 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd91e1de06..ccf1fc212b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,10 +45,10 @@ jobs: echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" cachix-build-check: - # Mirror check in cachix-build to skip forks. - if: | - !cancelled() && - (github.event_name == 'push' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) + # Holds no secrets, so it runs on fork PRs too: forks need its inputs-hash + # output to restore the base branch's publish marker and download the + # prebuilt binaries. Only cachix-build itself is fork-guarded. + if: ${{ !cancelled() }} timeout-minutes: 10 runs-on: ubuntu-latest permissions: @@ -149,14 +149,16 @@ jobs: CACHIX_CACHE_NAME: dimensionalos run: python3 bin/build-native-modules --verify-published - name: Record the published inputs manifest - # Forensics for the marker saved below: the manifest names every tree - # hash behind the inputs-hash this publish ran for. + # The manifest is forensics for the marker saved below; links.txt maps + # each result symlink to its store path so consumers can materialise + # the binaries with --link-results instead of re-running nix. env: CACHIX_CACHE_NAME: dimensionalos run: | mkdir -p .cachix-marker python3 bin/build-native-modules --inputs-hash \ > .cachix-marker/inputs-hash.txt 2> .cachix-marker/manifest.txt + python3 bin/build-native-modules --record-links > .cachix-marker/links.txt - name: Save publish marker uses: actions/cache/save@v6 with: @@ -563,6 +565,28 @@ jobs: - name: Set PYTHON_GIL=0 for free-threading builds if: ${{ endsWith(matrix.pyver, 't') }} run: echo "PYTHON_GIL=0" >> $GITHUB_ENV + # The published store paths are x86_64-linux, so the arm leg (and any + # future macOS leg) skips provisioning; binary-needing tests skip there. + # On a marker miss (fork PR, eviction) the binaries are simply absent — + # hosted runners never fall back to building the heavy C++ closures. + - name: Install Nix (with Cachix substituter) + if: runner.os == 'Linux' && runner.arch == 'X64' + env: + INPUT_EXTRA_NIX_CONFIG: | + extra-substituters = https://dimensionalos.cachix.org + extra-trusted-public-keys = dimensionalos.cachix.org-1:20ynj6TjpoD3qTxkdNoeHtgs2G2pNvgAq1EQYLTHJXI= + INPUT_SET_AS_TRUSTED_USER: "true" + run: bash docker/ros/install-nix.sh + - name: Restore publish marker + if: runner.os == 'Linux' && runner.arch == 'X64' + id: native-marker + uses: actions/cache/restore@v6 + with: + path: .cachix-marker + key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ needs.cachix-build-check.outputs.inputs-hash }} + - name: Provision native modules from Cachix + if: steps.native-marker.outputs.cache-hit == 'true' + run: python3 bin/build-native-modules --link-results .cachix-marker/links.txt - name: Install dependencies run: uv sync --group tests --frozen - name: Run tests @@ -739,9 +763,25 @@ jobs: max-jobs = 0 EOF echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" - - name: Fetch native modules from Cachix + - name: Restore publish marker if: contains(matrix.markers, 'skipif_no_ros') - run: python3 bin/build-native-modules + id: native-marker + uses: actions/cache/restore@v6 + with: + path: .cachix-marker + key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ needs.cachix-build-check.outputs.inputs-hash }} + - name: Provision native modules from Cachix + if: contains(matrix.markers, 'skipif_no_ros') + # The marker records every result -> store path mapping, so the warm + # path is symlink recreation against the runner's persistent /nix plus + # substitution of anything missing by exact path — no nix evaluation. + # A missed marker (evicted) falls back to the full nix build. + run: | + if [ "${{ steps.native-marker.outputs.cache-hit }}" = "true" ]; then + python3 bin/build-native-modules --link-results .cachix-marker/links.txt + else + python3 bin/build-native-modules + fi - name: Run tests run: uv run pytest --cov=dimos/ --junitxml=junit.xml -m '(${{ matrix.markers }}) and not mujoco' - name: Re-run the failing tests with maximum verbosity diff --git a/bin/build-native-modules b/bin/build-native-modules index 2dff0a285d..686c356007 100755 --- a/bin/build-native-modules +++ b/bin/build-native-modules @@ -25,6 +25,14 @@ Modes: while the push daemon drains. cachix-action reports push failures without failing the build, so CI must run this before trusting a build enough to save the publish marker. + --record-links Print `result-symlink store-path` per built module. The + publish job stores this in the marker cache entry so consumers + can materialise binaries without evaluating anything. + --link-results FILE + Recreate the symlinks a --record-links run captured. Store + paths missing locally are fetched with `nix copy` by exact + path — no evaluation — so NativeModule sees its executable + and skips building at test time. Why git object hashes are sound gate keys: a nix sandbox only sees store paths, and for these flakes every store path copied out of the repo is some tracked @@ -405,17 +413,48 @@ def _in_cachix_cache(cache: str, store_path: str) -> bool: return False -def verify_published(modules: tuple[DiscoveredModule, ...]) -> None: - cache = os.environ.get("CACHIX_CACHE_NAME") - if not cache: - raise SystemExit("--verify-published needs CACHIX_CACHE_NAME set") - missing: dict[str, str] = {} # store path -> module qualname, for the error message +def _result_links(modules: tuple[DiscoveredModule, ...]) -> list[tuple[str, str]]: + """(repo-relative result symlink, store path) for every built module.""" + entries = [] for module in modules: links = sorted((REPO_ROOT / module.build_dir).glob("result*")) if not links: raise SystemExit(f"{module.build_dir}: no result symlink — run the build first") for link in links: - missing[os.readlink(link)] = module.qualname + entries.append((link.relative_to(REPO_ROOT).as_posix(), os.readlink(str(link)))) + return entries + + +def link_results(links_file: str) -> None: + """Recreate the result symlinks a publish recorded, fetching any store + paths the local store lacks by exact path — no nix evaluation at all.""" + entries = [line.split(" ", 1) for line in Path(links_file).read_text().splitlines() if line] + if not entries: + raise SystemExit(f"{links_file}: no recorded result links") + missing = sorted({store for _, store in entries if not os.path.exists(store)}) + if missing: + # nix-store -r substitutes each path's closure from every configured + # substituter: the module paths come from Cachix, but their nixpkgs + # runtime deps (glibc, pcl, openmp, …) exist only in cache.nixos.org — + # Cachix holds just the locally-built paths, so a single-store + # `nix copy --from` cannot materialise the closure. + _log(f"Substituting {len(missing)} store path(s)") + subprocess.run(["nix-store", "--realise", *missing], check=True) + for link, store in entries: + target = REPO_ROOT / link + if target.is_symlink() or target.exists(): + target.unlink() + os.symlink(store, target) + _log(f"LINK: {link} -> {store}") + + +def verify_published(modules: tuple[DiscoveredModule, ...]) -> None: + cache = os.environ.get("CACHIX_CACHE_NAME") + if not cache: + raise SystemExit("--verify-published needs CACHIX_CACHE_NAME set") + missing: dict[str, str] = {} # store path -> result link, for the error message + for link, store in _result_links(modules): + missing[store] = link _log(f"Verifying {len(missing)} out path(s) against {cache}.cachix.org") deadline = time.monotonic() + int(os.environ.get("PUSH_VERIFY_TIMEOUT") or 600) while True: @@ -464,9 +503,27 @@ def main() -> None: action="store_true", help="check the built out paths are downloadable from the Cachix cache", ) + mode.add_argument( + "--record-links", + action="store_true", + help="print each built module's result symlink and its store path", + ) + mode.add_argument( + "--link-results", + metavar="FILE", + help="recreate the result symlinks a --record-links run captured," + " nix-copying missing store paths by exact path (no evaluation)", + ) args = parser.parse_args() modules = discover() + if args.link_results: + link_results(args.link_results) + return + if args.record_links: + for link, store in _result_links(modules): + print(f"{link} {store}") + return if args.verify_published: verify_published(modules) return From 89b3d76c50646385e0cf2f974267b6e7f219f66f Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 28 Aug 2026 18:09:16 +0100 Subject: [PATCH 2/7] Fix --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccf1fc212b..20ee3c0b88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -664,6 +664,11 @@ jobs: options: --memory=6g --memory-swap=6g volumes: - /var/cache/dimos-root-cache:/root/.cache + # Persistent Nix store — without it every run reinstalls nix + # and re-downloads the modules' full runtime closure (~600 MB) + # through the runner's uplink. GC'd at the end of the job; the + # provision step roots the live closure so GC keeps it. + - /var/cache/dimos-nix:/nix markers: "self_hosted or skipif_no_ros" experimental: false - os: macOS @@ -782,6 +787,16 @@ jobs: else python3 bin/build-native-modules fi + # Root the out paths (and thereby their closures) so the end-of-job + # GC keeps them warm: the workspace result links die in the next + # run's `git clean`, so they cannot serve as roots themselves. + # Replacing the directory unroots superseded paths for GC to reap. + sudo mkdir -p /nix/var/nix/gcroots/dimos-native + sudo find /nix/var/nix/gcroots/dimos-native -maxdepth 1 -type l -delete + python3 bin/build-native-modules --record-links | cut -d' ' -f2 | sort -u | + while read -r p; do + sudo ln -sfn "$p" "/nix/var/nix/gcroots/dimos-native/$(basename "$p")" + done - name: Run tests run: uv run pytest --cov=dimos/ --junitxml=junit.xml -m '(${{ matrix.markers }}) and not mujoco' - name: Re-run the failing tests with maximum verbosity @@ -842,6 +857,13 @@ jobs: size_mb=$(du -sm "$UV_CACHE_DIR" | cut -f1) echo "uv cache size: ${size_mb} MB" if [ "$size_mb" -gt 25600 ]; then uv cache prune --ci; fi + - name: GC the persistent Nix store + # /nix persists on the runner (container volume above), so superseded + # module closures accumulate as inputs change. Unrooted paths go; the + # provision step's gcroots keep the live closure across runs. Not + # always(): a cancelled run can leave nix processes holding locks. + if: ${{ !cancelled() && matrix.os == 'Linux' }} + run: nix-collect-garbage --delete-older-than 3d self-hosted-large-tests: # Skip on PRs from forks which would expose the self-hosted runner to untrusted code from external contributors. From 20decb050034d0fe6bd2359a412d959714ac9d1c Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 8 Sep 2026 18:54:56 +0100 Subject: [PATCH 3/7] Skip on marker --- .github/workflows/ci.yml | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20ee3c0b88..366c1c3eb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -569,14 +569,6 @@ jobs: # future macOS leg) skips provisioning; binary-needing tests skip there. # On a marker miss (fork PR, eviction) the binaries are simply absent — # hosted runners never fall back to building the heavy C++ closures. - - name: Install Nix (with Cachix substituter) - if: runner.os == 'Linux' && runner.arch == 'X64' - env: - INPUT_EXTRA_NIX_CONFIG: | - extra-substituters = https://dimensionalos.cachix.org - extra-trusted-public-keys = dimensionalos.cachix.org-1:20ynj6TjpoD3qTxkdNoeHtgs2G2pNvgAq1EQYLTHJXI= - INPUT_SET_AS_TRUSTED_USER: "true" - run: bash docker/ros/install-nix.sh - name: Restore publish marker if: runner.os == 'Linux' && runner.arch == 'X64' id: native-marker @@ -584,6 +576,15 @@ jobs: with: path: .cachix-marker key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ needs.cachix-build-check.outputs.inputs-hash }} + - name: Install Nix (with Cachix substituter) + # cache-hit implies the restore ran, which is already Linux/X64-gated. + if: steps.native-marker.outputs.cache-hit == 'true' + env: + INPUT_EXTRA_NIX_CONFIG: | + extra-substituters = https://dimensionalos.cachix.org + extra-trusted-public-keys = dimensionalos.cachix.org-1:20ynj6TjpoD3qTxkdNoeHtgs2G2pNvgAq1EQYLTHJXI= + INPUT_SET_AS_TRUSTED_USER: "true" + run: bash docker/ros/install-nix.sh - name: Provision native modules from Cachix if: steps.native-marker.outputs.cache-hit == 'true' run: python3 bin/build-native-modules --link-results .cachix-marker/links.txt From cc46a95650aa9751e491490081ff0b4eecee9681 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 8 Sep 2026 16:44:42 +0100 Subject: [PATCH 4/7] Build native modules for Mac --- .github/workflows/ci.yml | 130 +++++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 366c1c3eb0..dec962d3a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,8 @@ jobs: env: CACHIX_CACHE_NAME: dimensionalos outputs: - needs-build: ${{ steps.decide.outputs.needs-build }} + needs-build-linux: ${{ steps.decide.outputs.linux }} + needs-build-macos: ${{ steps.decide.outputs.macos }} inputs-hash: ${{ steps.hash.outputs.inputs-hash }} steps: @@ -65,24 +66,36 @@ jobs: - name: Compute native-module inputs hash id: hash run: python3 bin/build-native-modules --inputs-hash - - name: Probe publish marker - id: marker + - name: Probe Linux publish marker + id: marker-linux uses: actions/cache/restore@v6 with: path: .cachix-marker - key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ steps.hash.outputs.inputs-hash }} + key: cachix-published-Linux-X64-${{ steps.hash.outputs.inputs-hash }} lookup-only: true - - name: Decide whether a publish is needed + - name: Probe macOS publish marker + id: marker-macos + uses: actions/cache/restore@v6 + with: + path: .cachix-marker + key: cachix-published-macOS-ARM64-${{ steps.hash.outputs.inputs-hash }} + lookup-only: true + - name: Decide which platforms need a publish id: decide env: FORCE: ${{ inputs.force-cachix-build }} - MARKER_HIT: ${{ steps.marker.outputs.cache-hit }} + LINUX_HIT: ${{ steps.marker-linux.outputs.cache-hit }} + MACOS_HIT: ${{ steps.marker-macos.outputs.cache-hit }} run: | - if [ "$FORCE" != "true" ] && [ "$MARKER_HIT" = "true" ]; then - echo "needs-build=false" >> "$GITHUB_OUTPUT" - else - echo "needs-build=true" >> "$GITHUB_OUTPUT" - fi + emit() { # $1=output name, $2=this platform's marker hit + if [ "$FORCE" != "true" ] && [ "$2" = "true" ]; then + echo "$1=false" >> "$GITHUB_OUTPUT" + else + echo "$1=true" >> "$GITHUB_OUTPUT" + fi + } + emit linux "$LINUX_HIT" + emit macos "$MACOS_HIT" cachix-build: # ⚠️ NEVER RUN ON UNTRUSTED (fork) CODE ⚠️ @@ -101,7 +114,7 @@ jobs: if: | !cancelled() && (github.event_name == 'push' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && - needs.cachix-build-check.outputs.needs-build == 'true' + needs.cachix-build-check.outputs.needs-build-linux == 'true' needs: cachix-build-check timeout-minutes: 300 environment: cachix @@ -165,6 +178,71 @@ jobs: path: .cachix-marker key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ needs.cachix-build-check.outputs.inputs-hash }} + cachix-build-macos: + # ⚠️ NEVER RUN ON UNTRUSTED (fork) CODE ⚠️ — holds CACHIX_AUTH_TOKEN, same + # as cachix-build; the `if:` guard below is the only thing keeping forks + # out. A separate job (not a matrix leg of cachix-build) for two reasons: + # its own job-level `if` must gate entry into the `cachix` environment on + # the macOS marker alone (matrix context isn't available in `if:`), and a + # macOS build failure must not fail cachix-build and block the Linux tests. + if: | + !cancelled() && + (github.event_name == 'push' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && + needs.cachix-build-check.outputs.needs-build-macos == 'true' + needs: cachix-build-check + timeout-minutes: 300 + environment: cachix + runs-on: macos-14 # GitHub-hosted Apple silicon → runner.arch is ARM64 + permissions: + contents: read # For checkout + + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Install Nix (with Cachix substituter) + env: + INPUT_EXTRA_NIX_CONFIG: | + extra-substituters = https://dimensionalos.cachix.org + extra-trusted-public-keys = dimensionalos.cachix.org-1:20ynj6TjpoD3qTxkdNoeHtgs2G2pNvgAq1EQYLTHJXI= + INPUT_SET_AS_TRUSTED_USER: "true" + run: bash docker/ros/install-nix.sh + - name: Cache Nix fetcher + eval cache + # Key includes runner.os, so this is a macOS-only entry, distinct from + # the Linux fetcher cache. + uses: actions/cache@v6 + with: + path: | + ~/.cache/nix + ~/.local/state/nix + key: nix-fetcher-build-${{ runner.os }}-${{ hashFiles('bin/build-native-modules', '**/flake.lock', '**/flake.nix') }} + restore-keys: nix-fetcher-build-${{ runner.os }}- + - name: Authenticate Cachix + uses: cachix/cachix-action@v17 + with: + name: dimensionalos + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + - name: Build native modules + env: + BUILD_WORKERS: "1" + run: python3 bin/build-native-modules + - name: Verify Cachix holds the built paths + env: + CACHIX_CACHE_NAME: dimensionalos + run: python3 bin/build-native-modules --verify-published + - name: Record the published inputs manifest + env: + CACHIX_CACHE_NAME: dimensionalos + run: | + mkdir -p .cachix-marker + python3 bin/build-native-modules --inputs-hash \ + > .cachix-marker/inputs-hash.txt 2> .cachix-marker/manifest.txt + python3 bin/build-native-modules --record-links > .cachix-marker/links.txt + - name: Save publish marker + uses: actions/cache/save@v6 + with: + path: .cachix-marker + key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ needs.cachix-build-check.outputs.inputs-hash }} + lint: timeout-minutes: 10 runs-on: ubuntu-latest @@ -644,7 +722,7 @@ jobs: (github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository) && needs.cachix-build-check.result == 'success' && contains(fromJSON('["success", "skipped"]'), needs.cachix-build.result) - needs: [compute-ros-pin, cachix-build-check, cachix-build] + needs: [compute-ros-pin, cachix-build-check, cachix-build, cachix-build-macos] env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -770,16 +848,14 @@ jobs: EOF echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" - name: Restore publish marker - if: contains(matrix.markers, 'skipif_no_ros') id: native-marker uses: actions/cache/restore@v6 with: path: .cachix-marker key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ needs.cachix-build-check.outputs.inputs-hash }} - name: Provision native modules from Cachix - if: contains(matrix.markers, 'skipif_no_ros') # The marker records every result -> store path mapping, so the warm - # path is symlink recreation against the runner's persistent /nix plus + # path is symlink recreation against the persistent /nix plus # substitution of anything missing by exact path — no nix evaluation. # A missed marker (evicted) falls back to the full nix build. run: | @@ -788,16 +864,18 @@ jobs: else python3 bin/build-native-modules fi - # Root the out paths (and thereby their closures) so the end-of-job - # GC keeps them warm: the workspace result links die in the next - # run's `git clean`, so they cannot serve as roots themselves. - # Replacing the directory unroots superseded paths for GC to reap. - sudo mkdir -p /nix/var/nix/gcroots/dimos-native - sudo find /nix/var/nix/gcroots/dimos-native -maxdepth 1 -type l -delete - python3 bin/build-native-modules --record-links | cut -d' ' -f2 | sort -u | - while read -r p; do - sudo ln -sfn "$p" "/nix/var/nix/gcroots/dimos-native/$(basename "$p")" - done + # Linux only: root the out paths so this job's end-of-GC keeps the + # closure warm in the CI-managed bind-mounted store (the workspace + # result links die in the next run's `git clean`, so they cannot + # serve as roots). The macOS host manages its own store and GC. + if [ "${{ matrix.os }}" = "Linux" ]; then + sudo mkdir -p /nix/var/nix/gcroots/dimos-native + sudo find /nix/var/nix/gcroots/dimos-native -maxdepth 1 -type l -delete + python3 bin/build-native-modules --record-links | cut -d' ' -f2 | sort -u | + while read -r p; do + sudo ln -sfn "$p" "/nix/var/nix/gcroots/dimos-native/$(basename "$p")" + done + fi - name: Run tests run: uv run pytest --cov=dimos/ --junitxml=junit.xml -m '(${{ matrix.markers }}) and not mujoco' - name: Re-run the failing tests with maximum verbosity From f4745c0cf3aa77400cc7c7e37bc1977c561ff922 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Tue, 8 Sep 2026 16:44:42 +0100 Subject: [PATCH 5/7] Build native modules for Mac --- .github/workflows/ci.yml | 130 +++++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edfdf3c48c..e1bdfa5de2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,8 @@ jobs: env: CACHIX_CACHE_NAME: dimensionalos outputs: - needs-build: ${{ steps.decide.outputs.needs-build }} + needs-build-linux: ${{ steps.decide.outputs.linux }} + needs-build-macos: ${{ steps.decide.outputs.macos }} inputs-hash: ${{ steps.hash.outputs.inputs-hash }} steps: @@ -65,24 +66,36 @@ jobs: - name: Compute native-module inputs hash id: hash run: python3 bin/build-native-modules --inputs-hash - - name: Probe publish marker - id: marker + - name: Probe Linux publish marker + id: marker-linux uses: actions/cache/restore@v6 with: path: .cachix-marker - key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ steps.hash.outputs.inputs-hash }} + key: cachix-published-Linux-X64-${{ steps.hash.outputs.inputs-hash }} lookup-only: true - - name: Decide whether a publish is needed + - name: Probe macOS publish marker + id: marker-macos + uses: actions/cache/restore@v6 + with: + path: .cachix-marker + key: cachix-published-macOS-ARM64-${{ steps.hash.outputs.inputs-hash }} + lookup-only: true + - name: Decide which platforms need a publish id: decide env: FORCE: ${{ inputs.force-cachix-build }} - MARKER_HIT: ${{ steps.marker.outputs.cache-hit }} + LINUX_HIT: ${{ steps.marker-linux.outputs.cache-hit }} + MACOS_HIT: ${{ steps.marker-macos.outputs.cache-hit }} run: | - if [ "$FORCE" != "true" ] && [ "$MARKER_HIT" = "true" ]; then - echo "needs-build=false" >> "$GITHUB_OUTPUT" - else - echo "needs-build=true" >> "$GITHUB_OUTPUT" - fi + emit() { # $1=output name, $2=this platform's marker hit + if [ "$FORCE" != "true" ] && [ "$2" = "true" ]; then + echo "$1=false" >> "$GITHUB_OUTPUT" + else + echo "$1=true" >> "$GITHUB_OUTPUT" + fi + } + emit linux "$LINUX_HIT" + emit macos "$MACOS_HIT" cachix-build: # ⚠️ NEVER RUN ON UNTRUSTED (fork) CODE ⚠️ @@ -101,7 +114,7 @@ jobs: if: | !cancelled() && (github.event_name == 'push' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && - needs.cachix-build-check.outputs.needs-build == 'true' + needs.cachix-build-check.outputs.needs-build-linux == 'true' needs: cachix-build-check timeout-minutes: 300 environment: cachix @@ -165,6 +178,71 @@ jobs: path: .cachix-marker key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ needs.cachix-build-check.outputs.inputs-hash }} + cachix-build-macos: + # ⚠️ NEVER RUN ON UNTRUSTED (fork) CODE ⚠️ — holds CACHIX_AUTH_TOKEN, same + # as cachix-build; the `if:` guard below is the only thing keeping forks + # out. A separate job (not a matrix leg of cachix-build) for two reasons: + # its own job-level `if` must gate entry into the `cachix` environment on + # the macOS marker alone (matrix context isn't available in `if:`), and a + # macOS build failure must not fail cachix-build and block the Linux tests. + if: | + !cancelled() && + (github.event_name == 'push' || github.event_name == 'merge_group' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && + needs.cachix-build-check.outputs.needs-build-macos == 'true' + needs: cachix-build-check + timeout-minutes: 300 + environment: cachix + runs-on: macos-14 # GitHub-hosted Apple silicon → runner.arch is ARM64 + permissions: + contents: read # For checkout + + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Install Nix (with Cachix substituter) + env: + INPUT_EXTRA_NIX_CONFIG: | + extra-substituters = https://dimensionalos.cachix.org + extra-trusted-public-keys = dimensionalos.cachix.org-1:20ynj6TjpoD3qTxkdNoeHtgs2G2pNvgAq1EQYLTHJXI= + INPUT_SET_AS_TRUSTED_USER: "true" + run: bash docker/ros/install-nix.sh + - name: Cache Nix fetcher + eval cache + # Key includes runner.os, so this is a macOS-only entry, distinct from + # the Linux fetcher cache. + uses: actions/cache@v6 + with: + path: | + ~/.cache/nix + ~/.local/state/nix + key: nix-fetcher-build-${{ runner.os }}-${{ hashFiles('bin/build-native-modules', '**/flake.lock', '**/flake.nix') }} + restore-keys: nix-fetcher-build-${{ runner.os }}- + - name: Authenticate Cachix + uses: cachix/cachix-action@v17 + with: + name: dimensionalos + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + - name: Build native modules + env: + BUILD_WORKERS: "1" + run: python3 bin/build-native-modules + - name: Verify Cachix holds the built paths + env: + CACHIX_CACHE_NAME: dimensionalos + run: python3 bin/build-native-modules --verify-published + - name: Record the published inputs manifest + env: + CACHIX_CACHE_NAME: dimensionalos + run: | + mkdir -p .cachix-marker + python3 bin/build-native-modules --inputs-hash \ + > .cachix-marker/inputs-hash.txt 2> .cachix-marker/manifest.txt + python3 bin/build-native-modules --record-links > .cachix-marker/links.txt + - name: Save publish marker + uses: actions/cache/save@v6 + with: + path: .cachix-marker + key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ needs.cachix-build-check.outputs.inputs-hash }} + lint: timeout-minutes: 10 runs-on: ubuntu-latest @@ -645,7 +723,7 @@ jobs: (github.event_name == 'push' || github.event_name == 'merge_group' || github.event.pull_request.head.repo.full_name == github.repository) && needs.cachix-build-check.result == 'success' && contains(fromJSON('["success", "skipped"]'), needs.cachix-build.result) - needs: [compute-ros-pin, cachix-build-check, cachix-build] + needs: [compute-ros-pin, cachix-build-check, cachix-build, cachix-build-macos] env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} @@ -771,16 +849,14 @@ jobs: EOF echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" - name: Restore publish marker - if: contains(matrix.markers, 'skipif_no_ros') id: native-marker uses: actions/cache/restore@v6 with: path: .cachix-marker key: cachix-published-${{ runner.os }}-${{ runner.arch }}-${{ needs.cachix-build-check.outputs.inputs-hash }} - name: Provision native modules from Cachix - if: contains(matrix.markers, 'skipif_no_ros') # The marker records every result -> store path mapping, so the warm - # path is symlink recreation against the runner's persistent /nix plus + # path is symlink recreation against the persistent /nix plus # substitution of anything missing by exact path — no nix evaluation. # A missed marker (evicted) falls back to the full nix build. run: | @@ -789,16 +865,18 @@ jobs: else python3 bin/build-native-modules fi - # Root the out paths (and thereby their closures) so the end-of-job - # GC keeps them warm: the workspace result links die in the next - # run's `git clean`, so they cannot serve as roots themselves. - # Replacing the directory unroots superseded paths for GC to reap. - sudo mkdir -p /nix/var/nix/gcroots/dimos-native - sudo find /nix/var/nix/gcroots/dimos-native -maxdepth 1 -type l -delete - python3 bin/build-native-modules --record-links | cut -d' ' -f2 | sort -u | - while read -r p; do - sudo ln -sfn "$p" "/nix/var/nix/gcroots/dimos-native/$(basename "$p")" - done + # Linux only: root the out paths so this job's end-of-GC keeps the + # closure warm in the CI-managed bind-mounted store (the workspace + # result links die in the next run's `git clean`, so they cannot + # serve as roots). The macOS host manages its own store and GC. + if [ "${{ matrix.os }}" = "Linux" ]; then + sudo mkdir -p /nix/var/nix/gcroots/dimos-native + sudo find /nix/var/nix/gcroots/dimos-native -maxdepth 1 -type l -delete + python3 bin/build-native-modules --record-links | cut -d' ' -f2 | sort -u | + while read -r p; do + sudo ln -sfn "$p" "/nix/var/nix/gcroots/dimos-native/$(basename "$p")" + done + fi - name: Run tests run: uv run pytest --cov=dimos/ --junitxml=junit.xml -m '(${{ matrix.markers }}) and not mujoco' - name: Re-run the failing tests with maximum verbosity From 090bfff54eacd0e2b462c99fc9beba5a8c42b06a Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Thu, 10 Sep 2026 16:19:24 +0100 Subject: [PATCH 6/7] Skip nix when build already exists --- .../memory/test_rust_recorder_e2e.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/dimos/experimental/memory/test_rust_recorder_e2e.py b/dimos/experimental/memory/test_rust_recorder_e2e.py index a5c4c7e025..d26a261651 100644 --- a/dimos/experimental/memory/test_rust_recorder_e2e.py +++ b/dimos/experimental/memory/test_rust_recorder_e2e.py @@ -75,19 +75,21 @@ def stop(self) -> None: @pytest.fixture(scope="module") def rust_recorder_executable() -> Path: - subprocess.run( - [ - "nix", - "--extra-experimental-features", - "nix-command flakes", - "build", - "-L", - ".#dimos-memory-recorder", - "--no-write-lock-file", - ], - cwd=_RUST_PACKAGE, - check=True, - ) + # Build locally. Should already be built in CI. + if not _EXECUTABLE.is_file(): + subprocess.run( + [ + "nix", + "--extra-experimental-features", + "nix-command flakes", + "build", + "-L", + ".#dimos-memory-recorder", + "--no-write-lock-file", + ], + cwd=_RUST_PACKAGE, + check=True, + ) assert _EXECUTABLE.is_file() return _EXECUTABLE From 9c27343c2232bbba1a76f7bc6a0f2e5555046d25 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 11 Sep 2026 16:03:12 +0100 Subject: [PATCH 7/7] Fix nix path --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3268d1b225..487180fa9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -849,6 +849,16 @@ jobs: max-jobs = 0 EOF echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + - name: Put Nix on PATH (macOS) + if: matrix.os == 'macOS' + run: | + bin=/nix/var/nix/profiles/default/bin + if [ ! -x "$bin/nix-store" ]; then + echo "::error::Nix is not installed on this macOS runner host — install multi-user Nix and add the dimensionalos substituter to /etc/nix/nix.conf (see this step's comment)" + exit 1 + fi + echo "$bin" >> "$GITHUB_PATH" + echo "NIX_CONFIG=experimental-features = nix-command flakes" >> "$GITHUB_ENV" - name: Restore publish marker id: native-marker uses: actions/cache/restore@v6