Skip to content

[CELL-292] docker-build hydrates cleanly on both arches now — switch … #218

[CELL-292] docker-build hydrates cleanly on both arches now — switch …

[CELL-292] docker-build hydrates cleanly on both arches now — switch … #218

Workflow file for this run

name: Dev Build
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
on:
push:
branches:
- main
- feature/wip
- feature/add-web
- feature/web
workflow_dispatch:
inputs:
skip_nix_cache:
description: 'Skip nix cache (genesis mode — full rebuild, no pre-seeding)'
type: boolean
default: false
permissions:
contents: write
packages: write
id-token: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
secrets:
name: Detect Secrets
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
# gitleaks CLI directly — bypasses gitleaks-action's org-license
# requirement (the license gate lives in the action wrapper, not the
# scanner). `detect` exits 1 if leaks are found.
- name: Run gitleaks
run: |
docker run --rm -v "$PWD:/repo" -w /repo \
ghcr.io/gitleaks/gitleaks:latest \
detect --source=. --verbose --redact
docker-build:
name: Docker Build (${{ matrix.arch }})
needs: secrets
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
arch: amd64
platform: linux/amd64
- runner: ubuntu-24.04-arm
arch: arm64
platform: linux/arm64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
- name: Set lowercase image name
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
sudo docker image prune -af
df -h
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[worker.oci]
max-parallelism = 4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: Install go-task
uses: arduino/setup-task@v2
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
# task cell:build = swagger:generate → CGO_ENABLED=0 go build with ldflags
# for GitCommit + BuildDate. Single source of truth shared with local dev
# (`task cell:build` works the same on a developer machine).
- name: Build cell binary
run: task cell:build
# crane = google/go-containerregistry CLI. We use it to stream the
# nix-store volume directly to/from a GHCR layer blob without going
# through `docker buildx` or `docker pull`'s overlay2 extract.
# Push: tar -czf - | crane append → registry (no 32 GB tar on disk).
# Pull: crane blob | gunzip | tar -x → volume (no overlay2 extract).
# Net: ~32 GB less peak transient disk per job vs the Docker-native
# round-trip.
- name: Install crane
run: |
set -eo pipefail
VER=v0.20.6
case "${{ matrix.arch }}" in
amd64) ASSET=x86_64 ;;
arm64) ASSET=arm64 ;;
*) echo "unsupported arch ${{ matrix.arch }}"; exit 1 ;;
esac
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${VER}/go-containerregistry_Linux_${ASSET}.tar.gz" \
| sudo tar -xzC /usr/local/bin crane
crane version
# Cache the nix-store as a GHCR image. Lives in the SAME
# `devcell-sh/devcell` GHCR package as the runtime images, with tag
# prefix `nix-cache-` so it doesn't collide with stack tags.
# Hydrate via crane streaming (skip docker pull's overlay2 round-trip).
- name: Stream-hydrate /nix volume from prior GHCR cache (if available)
if: inputs.skip_nix_cache != true
run: |
set -eo pipefail
HASH=${{ hashFiles('nixhome/**') }}
EXACT="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache2-${{ matrix.arch }}-${HASH}"
LATEST="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache2-${{ matrix.arch }}-latest"
# Pick a candidate, then verify its layer is non-trivial (>1 GB)
# to skip past poisoned caches from prior broken push attempts
# (a partial push can produce a valid-looking tag with an empty
# nix-store layer; without this guard, hydrate "succeeds" with
# zero bytes and the build silently starts from scratch + the
# poisoned cache propagates forever).
IMG=""
for c in "$EXACT" "$LATEST"; do
if ! crane manifest "$c" >/dev/null 2>&1; then continue; fi
SIZE=$(crane manifest "$c" | jq -r '.layers[-1].size // 0')
if [ "${SIZE:-0}" -lt 1000000000 ]; then
echo "$c exists but layer is only ${SIZE} bytes — ignoring (poisoned cache)"
continue
fi
IMG="$c"
break
done
if [ -z "$IMG" ]; then
echo "cache MISS — starting from empty volume"
exit 0
fi
echo "cache HIT: $IMG"
# The appended nix-store layer is the LAST one (atop the busybox
# base). Stream the blob → ONE gunzip → tar -x into the volume.
# Single-gunzip is correct because the push step writes the
# gzipped tar to a FILE first and passes the file to
# `crane append --new_layer FILE` (crane detects gzip magic in
# the file content and stores it as-is, single-gzipped on the
# registry). Earlier `tar -czf | crane append --new_layer -`
# stdin pipeline produced a double-gzipped layer on amd64
# (CI #217 failure mode); file-based push is deterministic.
LAYER_DIGEST=$(crane manifest "$IMG" | jq -r '.layers[-1].digest')
docker volume rm devcell-nix-store-${{ matrix.arch }} 2>/dev/null || true
docker volume create devcell-nix-store-${{ matrix.arch }} >/dev/null
crane blob "$IMG@$LAYER_DIGEST" \
| gunzip \
| docker run --rm -i \
-v devcell-nix-store-${{ matrix.arch }}:/dest \
alpine sh -c 'cd /dest && tar -x --strip-components=1'
docker run --rm -v devcell-nix-store-${{ matrix.arch }}:/nix:ro alpine \
sh -c 'echo "hydrated $(du -sh /nix | cut -f1)"'
# Build both stacks sequentially in the same job so the nix-store
# volume accumulates derivations from both — single tar dump at job
# end carries everything needed by docker-test. `cell build --thin`
# reuses store paths across the two invocations (nix is
# content-addressed), so ultimate after base is incremental.
- name: Build thin image (base stack)
env:
DEVCELL_NIX_VOLUME: devcell-nix-store-${{ matrix.arch }}
DEVCELL_NIXHOME_PATH: ${{ github.workspace }}/nixhome
run: |
BASE_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-base"
./bin/cell build --thin --stack base --image "$BASE_TAG" --debug
echo "BASE_TAG=$BASE_TAG" >> "$GITHUB_ENV"
- name: Build thin image (ultimate stack)
env:
DEVCELL_NIX_VOLUME: devcell-nix-store-${{ matrix.arch }}
DEVCELL_NIXHOME_PATH: ${{ github.workspace }}/nixhome
run: |
ULT_TAG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-ultimate"
./bin/cell build --thin --stack ultimate --image "$ULT_TAG" --debug
echo "ULT_TAG=$ULT_TAG" >> "$GITHUB_ENV"
- name: Push to GHCR (both stacks)
run: |
docker push "$BASE_TAG"
docker push "$ULT_TAG"
# Inspect-and-publish: print volume metadata to logs and stream the
# populated /nix volume to GHCR as a single-layer cache image via
# crane (no intermediate tar file, no `docker buildx`). Peak transient
# disk: ~64 MB pipe buffer instead of ~64 GB.
- name: Inspect + stream-publish /nix volume to GHCR cache image
# Hard upper bound: arm64 publishes in ~51 min, amd64 hung 76 min on
# the prior attempt before some implicit kill. Cap at 80 min so we
# fail fast (with debug context above) instead of spinning forever.
timeout-minutes: 80
run: |
set -eo pipefail
# pv = pipe-viewer for byte-throughput monitoring on the runner
# side. Apt-fetch is ~1 sec, negligible vs the publish runtime.
sudo apt-get update -qq && sudo apt-get install -y -qq pv
echo "==== Runner snapshot ===="
uname -a
echo "cpus: $(nproc) arch: $(uname -m)"
free -h
df -h /var/lib/docker /
echo
docker volume ls --filter name=devcell-nix-store
# Container-side view: byte-exact size + entry counts so we can
# compare "what we tried to push" vs "what arrived on GHCR".
docker run --rm -v devcell-nix-store-${{ matrix.arch }}:/nix alpine sh -c '
echo "--- /nix size (human) ---"
du -sh /nix
echo "--- /nix size (bytes) ---"
du -sb /nix
echo "--- top-level ---"
ls /nix | head -20
echo "--- /nix/store entry count ---"
find /nix/store -mindepth 1 -maxdepth 1 2>/dev/null | wc -l
echo "--- /nix/store sample ---"
ls /nix/store 2>/dev/null | head -5 || echo "(no /nix/store)"
'
echo
HASH=${{ hashFiles('nixhome/**') }}
EXACT="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache2-${{ matrix.arch }}-${HASH}"
LATEST="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache2-${{ matrix.arch }}-latest"
# Background heartbeat: every 30s print disk free + load avg so
# we can correlate stalls with disk fill, OOM, or network
# backpressure. Killed via EXIT trap so it never outlives the
# foreground pipeline.
(
while true; do
sleep 30
printf "[heartbeat %s] disk-free=%s loadavg=%s\n" \
"$(date -u +%H:%M:%SZ)" \
"$(df -h /var/lib/docker | awk 'NR==2 {print $4}')" \
"$(awk '{print $1}' /proc/loadavg)"
done
) &
HB_PID=$!
trap "kill $HB_PID 2>/dev/null || true" EXIT
# Stream gzipped tar from the volume → pv (throughput monitor) →
# crane → registry as a single OCI layer atop a busybox base.
# crane uploads the layer blob in chunks while tar is still
# emitting bytes, so the network side is streamed.
# CRITICAL: emit gzipped tar (`-czf`, not `-cf`).
# `crane append --new_layer -` buffers stdin to a temp file in
# the runner's disk to compute the layer digest before it can
# start the registry upload. Uncompressed `/nix` is ~32 GB —
# that temp file exhausts the runner's disk and crashes the
# runner worker itself (System.IO.IOException: No space left
# on device on `/home/runner/extracted/_diag/Worker_*.log`,
# observed on commit 5a930e3, both arches). With `-czf` the
# pipe carries ~10 GB which fits the runner's free disk.
# crane does NOT re-compress already-gzipped input — empirical
# check: `crane blob ... | gunzip | head -c 4` on the resulting
# layer yields tar magic (`nix/`), not a second gzip wrapper.
# So pull side uses a single `gunzip | tar -x`.
# `-C / nix` (instead of `-C /nix .` + --transform) produces
# archive paths with the `nix/` prefix natively, so the layer's
# files land at /nix/* inside the cache image (matched by
# `--strip-components=1` on the pull side). We use this form
# because BusyBox tar (the default in alpine) doesn't implement
# GNU tar's --transform option.
# --exclude: Unix sockets can't be archived — pre-exclude the
# nix-daemon socket so tar doesn't emit a "socket ignored"
# warning that could trip stricter tar implementations.
# `pv -tabri 30 -f`: every 30s emit elapsed/avg-rate/cur-rate/
# bytes-transferred to stderr (-f forces output to a non-TTY).
# crane verbosity (`-v`) is DELIBERATELY OFF — when enabled it
# logs one stderr line per HTTP chunk upload, which at full
# upload speed flooded GitHub Actions' per-step log cap on
# commit f592686.
echo "==== Publish start ($(date -u +%H:%M:%SZ)) ===="
SECONDS=0
# Stage tar.gz to a temp file FIRST, then pass the file to
# `crane append --new_layer FILE`. Why file instead of stdin:
# `crane append --new_layer -` treats stdin as an uncompressed
# tarball and gzip-wraps it on upload, even if it's already
# gzipped — so `tar -czf | crane append --new_layer -` lands
# a double-gzipped layer on the registry. The file-based path
# detects gzip magic in the file content and stores it as-is.
# This makes the on-wire encoding deterministic (single-gzip)
# so the pull side's `crane blob | gunzip | tar -x` works
# without guessing how many gunzip passes to apply. Mirrors
# `test/cache_roundtrip_test.go`'s push exactly so the local
# test is a faithful regression net for this step.
# Disk cost: one ~11 GB temp file on the runner; runner has
# ~60 GB free after the Free-disk-space step, comfortable.
# crane append's `--new_tag` is `string` (singular), not
# `strings` — passing it twice silently overwrites, so push
# $LATEST then alias $EXACT via `crane tag` (manifest-only,
# no re-upload).
CACHE_TAR="${RUNNER_TEMP:-/tmp}/nix-cache-${{ matrix.arch }}.tar.gz"
docker run --rm -v devcell-nix-store-${{ matrix.arch }}:/nix:ro alpine \
tar -czf - \
--exclude='nix/var/nix/daemon-socket' \
-C / nix \
| pv -tabri 30 -f -N tar-bytes \
> "$CACHE_TAR"
echo "staged tarball: $(stat -c%s "$CACHE_TAR") bytes at $CACHE_TAR"
crane append \
--base public.ecr.aws/docker/library/busybox:latest \
--new_layer "$CACHE_TAR" \
--new_tag "$LATEST"
rm -f "$CACHE_TAR"
crane tag "$LATEST" "nix-cache2-${{ matrix.arch }}-${HASH}"
echo
echo "==== Publish end ($(date -u +%H:%M:%SZ)) — elapsed ${SECONDS}s ===="
# Sanity: confirm the layer we pushed is non-trivial. The earlier
# broken push (tar errored out, crane still published the busybox
# base + tiny manifest, exit code 1 but tag was set) poisoned the
# latest tag. Failing the step early on a too-small layer prevents
# a similar regression.
MANIFEST=$(crane manifest "$LATEST" 2>/dev/null || echo '{}')
LAYER_SIZE=$(echo "$MANIFEST" | jq -r '.layers[-1].size // 0')
echo "published layer size: ${LAYER_SIZE} bytes"
if [ "${LAYER_SIZE:-0}" -lt 1000000000 ]; then
echo "FAIL: cache layer is <1 GB — push produced a corrupt/empty manifest"
exit 1
fi
echo "pushed nix-cache images:"
echo " $EXACT"
echo " $LATEST"
df -h /var/lib/docker /
docker-test:
name: Docker Test (${{ matrix.arch }})
needs: docker-build
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
arch: amd64
- runner: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
- name: Set lowercase image name
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> $GITHUB_ENV
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: Install crane
run: |
set -eo pipefail
VER=v0.20.6
case "${{ matrix.arch }}" in
amd64) ASSET=x86_64 ;;
arm64) ASSET=arm64 ;;
*) echo "unsupported arch ${{ matrix.arch }}"; exit 1 ;;
esac
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${VER}/go-containerregistry_Linux_${ASSET}.tar.gz" \
| sudo tar -xzC /usr/local/bin crane
crane version
# Stream-pull the nix-store cache image's layer blob directly into the
# per-arch volume via crane, bypassing Docker's overlay2 extract +
# auto-seed copy (~32 GB less peak transient disk vs `docker pull`).
# Single seed: we set DEVCELL_NIX_VOLUME on the test step below so
# `cell shell` / `cell claude` use this per-arch volume directly
# instead of the default-named one — no need to seed two volumes.
- name: Stream-pull /nix cache from GHCR into volume
run: |
set -eo pipefail
HASH=${{ hashFiles('nixhome/**') }}
EXACT="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache2-${{ matrix.arch }}-${HASH}"
LATEST="${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:nix-cache2-${{ matrix.arch }}-latest"
# Pick first candidate whose nix-store layer is non-trivial (>1 GB).
# Filters out poisoned caches from partial pushes (a partial push
# can leave a valid-looking tag pointing at an empty layer; without
# this guard, hydrate "succeeds" with zero bytes and tests fall
# through to lazy-build at 2-minute timeout).
IMG=""
for c in "$EXACT" "$LATEST"; do
if ! crane manifest "$c" >/dev/null 2>&1; then continue; fi
SIZE=$(crane manifest "$c" | jq -r '.layers[-1].size // 0')
if [ "${SIZE:-0}" -lt 1000000000 ]; then
echo "$c exists but layer is only ${SIZE} bytes — ignoring (poisoned cache)"
continue
fi
IMG="$c"
break
done
if [ -z "$IMG" ]; then
echo "cache MISS — tests will run on empty /nix volume (lazy build during cell shell)"
exit 0
fi
echo "cache HIT: $IMG"
# ONE gunzip: the publish step writes gzipped tar to a FILE
# and uses `crane append --new_layer FILE`, which stores the
# file content as-is (single-gzipped on the registry).
# Mirrors the warm-start step in docker-build.
LAYER_DIGEST=$(crane manifest "$IMG" | jq -r '.layers[-1].digest')
docker volume rm devcell-nix-store-${{ matrix.arch }} 2>/dev/null || true
docker volume create devcell-nix-store-${{ matrix.arch }} >/dev/null
crane blob "$IMG@$LAYER_DIGEST" \
| gunzip \
| docker run --rm -i \
-v devcell-nix-store-${{ matrix.arch }}:/dest \
alpine sh -c 'cd /dest && tar -x --strip-components=1'
- name: Show volume status (debug)
run: |
docker volume ls --filter name=devcell-nix-store || true
docker run --rm -v devcell-nix-store-${{ matrix.arch }}:/nix:ro alpine \
sh -c 'du -sh /nix; ls /nix/store 2>/dev/null | head -3 || echo "(no /nix/store)"'
- name: Pull test images (base + ultimate)
run: |
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-base
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-ultimate
- name: Run container tests
# Both `DEVCELL_USER_IMAGE` and `DEVCELL_TEST_IMAGE` point at the
# ultimate image (every module + tool baked in) so module-aware tests
# (kicad, plex, gui) find what they need. `DEVCELL_TEST_BASE_IMAGE`
# points at the smaller base image for entrypoint-only tests
# (TestEntrypoint_DebugTimestamps etc.). Setting `DEVCELL_USER_IMAGE`
# without `DEVCELL_USER_IMAGE_THIN` works because UserImageTagThin()
# falls back to DEVCELL_USER_IMAGE as-is (no `-thin` suffix appended)
# per CELL-286 prep.
# `DEVCELL_NIX_VOLUME` points cell at the per-arch volume we just
# hydrated from the GHCR cache, so `cell shell` / `cell claude` skip
# the lazy-build path entirely instead of falling back to the
# default-named volume (which would be empty).
env:
DEVCELL_NIX_VOLUME: devcell-nix-store-${{ matrix.arch }}
DEVCELL_TEST_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-ultimate
DEVCELL_USER_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-ultimate
DEVCELL_TEST_BASE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-base
DEVCELL_TEST_PROJECT_DIR: ${{ runner.temp }}/devcell-test
MCP_SECRET_TEST_PASSWORD: fake-secret-ci-value
MCP_SECRET_GITHUB_TOKEN: fake-token-ci-value
run: go test -v -timeout 1200s ./test/...
docker-manifest:
name: Docker Manifests
needs: [docker-build]
if: always() && needs.docker-build.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Set lowercase image name
run: echo "IMAGE_NAME_LC=${IMAGE_NAME,,}" >> $GITHUB_ENV
env:
IMAGE_NAME: ${{ github.repository }}
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create GHCR manifests (per stack + ultimate as canonical)
run: |
R=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}
# Per-stack multi-arch manifests
docker buildx imagetools create \
-t "${R}:v0.0.0-base" \
"${R}:v0.0.0-amd64-base" \
"${R}:v0.0.0-arm64-base"
docker buildx imagetools create \
-t "${R}:v0.0.0-ultimate" \
-t "${R}:v0.0.0" \
-t "${R}:latest" \
-t "${R}:dev" \
"${R}:v0.0.0-amd64-ultimate" \
"${R}:v0.0.0-arm64-ultimate"
cell-build:
name: Cell CLI Dev Build
needs: secrets
runs-on: ubuntu-latest
env:
RELEASE_VERSION: v0.0.0
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache-dependency-path: go.sum
- name: Set SHORT_SHA
run: echo "SHORT_SHA=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_ENV
- name: Delete existing tag
run: |
git config --global user.email "dmitry@atd.sh"
git config --global user.name "Dmitry Kireev"
git tag -d "${{ env.RELEASE_VERSION }}" || echo "No local tag to delete"
git push origin :refs/tags/${{ env.RELEASE_VERSION }} || echo "No remote tag to delete"
- name: Add tag
run: |
git tag -a ${{ env.RELEASE_VERSION }} -m "Development release"
git push origin ${{ github.ref_name }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean -f .goreleaser.dev.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
SHORT_SHA: ${{ env.SHORT_SHA }}
- name: Publish release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release edit "${{ env.RELEASE_VERSION }}" --draft=false --latest=false --prerelease
e2e-install:
name: E2E Install (${{ matrix.arch }})
needs: [cell-build, docker-manifest]
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
arch: amd64
- runner: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
- name: Download cell binary
run: |
TARBALL="cell-linux-${{ matrix.arch }}.tar.gz"
URL="https://github.com/DimmKirr/devcell/releases/download/v0.0.0/${TARBALL}"
echo "Downloading: ${URL}"
curl -fsSL -o "${TARBALL}" "${URL}"
tar xzf "${TARBALL}"
chmod +x cell
sudo mv cell /usr/local/bin/cell
- name: Verify cell binary
run: |
cell --help
echo "--- cell binary OK ---"
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run cell claude --version (full pipeline)
env:
DEVCELL_NIXHOME_PATH: ${{ github.workspace }}/nixhome
run: |
# Simulate a new user in a fresh project dir
mkdir -p /tmp/e2e-project && cd /tmp/e2e-project
# --plain-text: disable spinners for CI
# Flow: scaffold devcell.toml → pull base image → build user image → start container → print claude version
OUTPUT=$(cell --plain-text claude --version 2>&1) || true
echo "$OUTPUT"
# Assert cell version string is present
if echo "$OUTPUT" | grep -qE "cell\s+v?[0-9]+\.[0-9]+\.[0-9]+"; then
echo "--- PASS: cell version found ---"
else
echo "--- FAIL: cell version string not found in output ---"
exit 1
fi
# Assert the image was built (user image should exist now)
if echo "$OUTPUT" | grep -qiE "(building|built|image)"; then
echo "--- PASS: image build occurred ---"
else
echo "--- WARN: no build output detected (image may have been cached) ---"
fi
brew-install:
name: Brew Install (${{ matrix.arch }})
needs: [cell-build]
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-latest
arch: amd64
- runner: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
steps:
- name: Install Homebrew
run: |
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" < /dev/null
echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH
- name: Brew install devcell-dev
run: |
brew tap devcell-sh/tap
# Homebrew 4.5+ refuses to load casks from third-party taps without
# explicit trust. `brew trust devcell-sh/tap` trusts every cask in
# the tap so non-interactive installs succeed.
brew trust devcell-sh/tap
brew install --cask devcell-dev || true
# Verify binary was actually linked despite potential broken pipe
if ! command -v cell &>/dev/null; then
echo "Binary not found, retrying..."
brew install --cask devcell-dev
fi
- name: Verify version
run: |
INSTALLED=$(cell --version)
echo "Installed: ${INSTALLED}"
if echo "${INSTALLED}" | grep -qE "^cell version [0-9]+\.[0-9]+\.[0-9]+"; then
echo "--- PASS: cell version OK ---"
else
echo "--- FAIL: unexpected version output ---"
exit 1
fi