Repository navigation
[CELL-292] docker-build hydrates cleanly on both arches now — switch … #218
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |