From 98fe573d82cf9b4dd85c97b3457b315ef8a7f1ef Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 12:54:55 +0000 Subject: [PATCH 01/21] ci: Run the Actor dev-loop e2e on the self-hosted macOS arm64 runner Adds an `e2e-macos-arm64` job to `ci.yml` that runs `actor-dev-loop.test.ts` on the `[self-hosted, macOS, ARM64]` runner - one file to start with, to prove the executor works end to end (runtime image build, container start, push / build / call through apify-cli against Docker Desktop's forwarded socket). The job is skipped for pull requests from forks, since the runner is a persistent Apify machine and the repository is public. Because that machine is never recreated, the job cleans up before and after the test with the new `.github/scripts/e2e-host-cleanup.sh`: the runtime container and its volume, the Actor run / probe / browser-viewer containers and volumes the runtime labels, the images it builds, dangling layers, and the suite's temp dirs. The pre-test pass also fails early with the holder named if 3333 or 3000 is still taken by something that is not ours. `startRuntimeContainer` now force-removes a stale container of its own name before `docker run`, so a run that died before `afterAll` (locally or on the runner) no longer fails the next one with "name is already in use". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/e2e-host-cleanup.sh | 106 ++++++++++++++++++++++++++++ .github/workflows/ci.yml | 56 ++++++++++++++- requirements/test.md | 1 + test/e2e/helpers/docker.ts | 10 +++ 4 files changed, 171 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/e2e-host-cleanup.sh diff --git a/.github/scripts/e2e-host-cleanup.sh b/.github/scripts/e2e-host-cleanup.sh new file mode 100755 index 00000000..e98f5e63 --- /dev/null +++ b/.github/scripts/e2e-host-cleanup.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Removes everything the e2e suite leaves on the host it ran on, so a persistent runner - the +# self-hosted macOS arm64 one in `ci.yml` - starts each job clean and leaves it clean. The hosted +# runners are ephemeral and never need this; there `stopRuntimeContainer`'s `afterAll` is enough, +# and the "Dump runtime container logs on failure" step relies on the container still being there. +# +# Scoped to what the suite and the runtime it drives create, nothing else on the machine: +# - the runtime container(s) `actor-runtime-e2e*` and their `-data` volumes +# (`test/e2e/helpers/docker.ts`), which hold the fixed host ports 3333/3000 +# - Actor run, dev-folder probe and browser-viewer containers/volumes the runtime creates, found by +# the labels `docker-driver.ts` puts on them (a runtime killed mid-run cannot clean them itself) +# - images the runtime builds (`actor-runtime/:`, `localhost/actor-runtime/*`) and +# the runtime image the suite builds (`actor-runtime:e2e`), plus whatever dangling layers retagging +# left behind. Pre-pulled base images stay: on a persistent host they are the cache that keeps the +# job fast, and the suite re-pulls them anyway. +# - the suite's temp dirs (`actor-runtime-e2e-*` under `$TMPDIR`, where Node's `os.tmpdir()` puts them) +# +# Usage: e2e-host-cleanup.sh [--require-free-ports] +# --require-free-ports fail once cleanup is done if 3333 or 3000 is still held by something this +# script does not own - meant for the pre-test step, where a busy port would +# otherwise fail `docker run` deep inside the suite with a far less clear error. +# +# Honors the suite's own engine selection: `CONTAINER_CLI=podman` drives Podman, `docker` otherwise. +set -euo pipefail + +cli=${CONTAINER_CLI:-docker} +[ "$cli" = podman ] || cli=docker + +require_free_ports=0 +for arg in "$@"; do + case "$arg" in + --require-free-ports) require_free_ports=1 ;; + *) + echo "Unknown argument: $arg" >&2 + exit 2 + ;; + esac +done + +# Whitespace-separated ids -> `$cli -f `; a no-op on an empty list. +remove() { + local kind=$1 + shift + local ids + ids=$(printf '%s\n' "$@" | sed '/^$/d' | sort -u) + [ -n "$ids" ] || return 0 + echo "Removing $kind:" + printf ' %s\n' $ids + # shellcheck disable=SC2086 + "$cli" "$kind" rm -f $ids >/dev/null || true +} + +if ! "$cli" info >/dev/null 2>&1; then + echo "::warning::$cli daemon is not reachable; nothing to clean up on the engine side (the suite itself will fail on this)." +else + remove container \ + "$("$cli" ps -aq --filter name=actor-runtime-e2e)" \ + "$("$cli" ps -aq --filter label=actor-runtime.runId)" \ + "$("$cli" ps -aq --filter label=actor-runtime.devFolderProbe)" \ + "$("$cli" ps -aq --filter label=actor-runtime.browserViewer)" + + remove volume \ + "$("$cli" volume ls -q --filter name=actor-runtime-e2e)" \ + "$("$cli" volume ls -q --filter label=actor-runtime.browserViewer)" + + remove image \ + "$("$cli" images -q --filter reference='actor-runtime:e2e')" \ + "$("$cli" images -q --filter reference='actor-runtime/*')" \ + "$("$cli" images -q --filter reference='localhost/actor-runtime/*')" + + # Only dangling (untagged, unreferenced) layers - retagging `actor-runtime:e2e` on every run leaves + # the previous image behind as one, and they add up on a machine that is never recreated. + "$cli" image prune -f >/dev/null || true +fi + +# `os.tmpdir()` in Node: `$TMPDIR` if set (macOS sets it per user, to a `/var/folders/...` path), +# `/tmp` otherwise - the same rule as here, so the glob lands where the suite's `mkdtemp` calls did. +tmp=${TMPDIR:-/tmp} +tmp=${tmp%/} +shopt -s nullglob +leftovers=("$tmp"/actor-runtime-e2e-*) +shopt -u nullglob +if [ ${#leftovers[@]} -gt 0 ]; then + echo "Removing temp dirs:" + printf ' %s\n' "${leftovers[@]}" + rm -rf "${leftovers[@]}" +fi + +if [ "$require_free_ports" -eq 1 ]; then + if ! command -v lsof >/dev/null 2>&1; then + echo "::warning::lsof not found; cannot verify that ports 3333 and 3000 are free." + exit 0 + fi + busy=0 + for port in 3333 3000; do + # `docker run -p` binds these on the host; anything still listening now is not ours to remove. + if holders=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null) && [ -n "$holders" ]; then + echo "::error::Port $port is still in use after cleanup - the e2e runtime container needs it:" + echo "$holders" + busy=1 + fi + done + [ "$busy" -eq 0 ] || exit 1 +fi + +echo "e2e host cleanup done." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6248a5a..1685bb43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,8 +90,9 @@ jobs: e2e-arm64: name: e2e actor-dev-loop (apify-cli + docker on arm64) - # The only arm64 coverage in CI: `release.yml` builds multi-arch but is dispatch-only, so an - # arm64 break would otherwise surface at release time. Native, so nothing here is emulated. + # The Linux arm64 coverage in CI (`e2e-macos-arm64` below is the other): `release.yml` builds + # multi-arch but is dispatch-only, so an arm64 break would otherwise surface at release time. + # Native, so nothing here is emulated. runs-on: ubuntu-24.04-arm timeout-minutes: 30 steps: @@ -111,3 +112,54 @@ jobs: for c in $(docker ps -aq --filter name=actor-runtime-e2e); do docker logs --tail 300 "$c" || true done + + e2e-macos-arm64: + name: e2e actor-dev-loop (apify-cli + docker on macOS arm64, self-hosted) + # The one leg on the platform most developers actually run the runtime on: Docker Desktop on an + # Apple Silicon Mac, where the engine lives in a VM and the socket the runtime container gets + # mounted (`/var/run/docker.sock`) is Docker Desktop's forwarded one, not a kernel socket. It is a + # persistent self-hosted machine, so unlike every hosted leg above it is cleaned before and after. + # + # Self-hosted, so never for a fork's pull request: `pull_request` runs the head branch's code, + # and the runner is a long-lived Apify machine. Pushes to master and same-repo PRs only. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: [self-hosted, macOS, ARM64] + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + # A previous job on this machine may have been cancelled or timed out mid-suite, leaving the + # runtime container holding 3333/3000 and Actor containers running. Fails if the ports are + # held by something that is not ours, rather than letting `docker run` fail deep in `beforeAll`. + - name: Clean up leftovers of earlier runs + run: .github/scripts/e2e-host-cleanup.sh --require-free-ports + # What the suite is about to talk to, for when a failure is the machine's setup rather than + # the code: the engine, its context, and the architecture it reports. + - name: Show container engine + run: | + uname -m + docker context show + docker version + docker info --format 'Arch={{.Architecture}} OS={{.OperatingSystem}} Server={{.ServerVersion}}' + ls -l /var/run/docker.sock + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + # Only this file for now - the smallest end-to-end exercise of the whole chain (build the + # runtime image, run it, push/build/call two Actors through apify-cli) on this executor. The + # browser-view and all-modes files are the candidates to add next: Docker Desktop on Apple + # Silicon emulates amd64 out of the box, which is what their fallback builds need. + - run: pnpm exec vitest run test/e2e/actor-dev-loop.test.ts + - name: Dump runtime container logs on failure + if: failure() + run: | + for c in $(docker ps -aq --filter name=actor-runtime-e2e); do + docker logs --tail 300 "$c" || true + done + # Always, so the next job starts clean whatever happened here - and so the suite's built + # images and temp dirs do not accumulate on a machine that is never recreated. + - name: Clean up + if: always() + run: .github/scripts/e2e-host-cleanup.sh diff --git a/requirements/test.md b/requirements/test.md index 495fadb9..c710435b 100644 --- a/requirements/test.md +++ b/requirements/test.md @@ -6,6 +6,7 @@ - CI (GitHub Actions) runs on every pull request and on pushes to the main branches: build, lint, format check, and all test layers, with the mandatory CLI-only e2e suite below executing against a real Docker daemon. A missing daemon fails the CI job - the e2e suite never silently skips. - CI runs each e2e file as its own job, in parallel; locally the files run one after another (each starts a runtime container on the fixed ports). +- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the Actor dev-loop e2e file on a self-hosted macOS arm64 runner with Docker Desktop - the setup most developers use the runtime from. That runner is a persistent machine, so the job removes everything the suite leaves behind (its runtime container and volume, the Actor containers and images the runtime built, its temp dirs) both before and after the test, and it never runs for pull requests from forks. # Mandatory end-to-end tests diff --git a/test/e2e/helpers/docker.ts b/test/e2e/helpers/docker.ts index bc5b201d..e3b8193e 100644 --- a/test/e2e/helpers/docker.ts +++ b/test/e2e/helpers/docker.ts @@ -88,6 +88,11 @@ export function pullImage(image: string): void { } export function startRuntimeContainer(tag: string, containerName: string): void { + // A previous run that died before its `afterAll` (killed vitest, a CI job cancelled or timed out on a + // persistent self-hosted runner) leaves a container of this name, and with it the fixed host ports + // below, in place; `docker run` would then fail with "name is already in use" / "port is already + // allocated". The name is this suite's own, so removing whatever holds it is always the right call. + removeContainerAndDataVolume(containerName); execFileSync( CONTAINER_CLI, [ @@ -135,6 +140,11 @@ export function stopRuntimeContainer(containerName: string): void { // best-effort: the container may already be gone (never started, already removed) - diagnostics // only, never a reason to skip the cleanup below. } + removeContainerAndDataVolume(containerName); +} + +/** Force-removes the runtime container and its `-data` volume; a no-op when neither exists. */ +function removeContainerAndDataVolume(containerName: string): void { try { execFileSync(CONTAINER_CLI, ['rm', '-f', containerName], { stdio: 'ignore' }); } catch { From f891f92cac7cc314eaa7153401a8e950d2088647 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 13:02:48 +0000 Subject: [PATCH 02/21] ci: Bring a Docker engine to the clean macOS runner and remove it after The self-hosted macOS arm64 runner ships with no Docker, Podman or OrbStack, so the e2e job now installs its own engine, entirely in user space, with `.github/scripts/macos-docker-engine.sh install`: the static Docker CLI 29.8.1, buildx v0.37.1 (the Dockerfile needs BuildKit), Lima 2.2.0 and Colima v0.10.3, all pinned and checksum-verified, under one directory in the job's temp dir. Colima runs the daemon in a Virtualization.framework VM; the docker context it activates is what the CLI uses, and `DOCKER_HOST` stays unset so the suite keeps mounting `/var/run/docker.sock`, which inside the VM is the daemon's socket. The install smoke-tests exactly that mount before the suite runs. `teardown` (always) stops and deletes the VM, removes the binaries, Colima's state, the isolated `DOCKER_CONFIG` and Lima's image cache, leaving the Mac as the job found it. The runner user's own `~/.docker`, `~/.colima` and `~/.lima` are never touched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/e2e-host-cleanup.sh | 2 + .github/scripts/macos-docker-engine.sh | 199 +++++++++++++++++++++++++ .github/workflows/ci.yml | 46 +++--- requirements/test.md | 2 +- 4 files changed, 227 insertions(+), 22 deletions(-) create mode 100755 .github/scripts/macos-docker-engine.sh diff --git a/.github/scripts/e2e-host-cleanup.sh b/.github/scripts/e2e-host-cleanup.sh index e98f5e63..0abb8105 100755 --- a/.github/scripts/e2e-host-cleanup.sh +++ b/.github/scripts/e2e-host-cleanup.sh @@ -3,6 +3,8 @@ # self-hosted macOS arm64 one in `ci.yml` - starts each job clean and leaves it clean. The hosted # runners are ephemeral and never need this; there `stopRuntimeContainer`'s `afterAll` is enough, # and the "Dump runtime container logs on failure" step relies on the container still being there. +# On the macOS leg the engine itself is per-job (`macos-docker-engine.sh`), so the engine-side part +# below finds little to do there; the temp dirs and the port check are what it is for on that runner. # # Scoped to what the suite and the runtime it drives create, nothing else on the machine: # - the runtime container(s) `actor-runtime-e2e*` and their `-data` volumes diff --git a/.github/scripts/macos-docker-engine.sh b/.github/scripts/macos-docker-engine.sh new file mode 100755 index 00000000..86cc044b --- /dev/null +++ b/.github/scripts/macos-docker-engine.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# Puts a Docker engine on a clean macOS arm64 host for the e2e suite, and takes it away again - +# for the self-hosted runner in `ci.yml`, which has no Docker, Podman or OrbStack of its own. +# +# Everything is user space: no sudo, no Homebrew, no GUI login session, nothing under /usr/local or +# /Applications. The pieces, all pinned, all downloaded straight from their projects' releases and +# verified against the checksums those releases publish: +# - the static Docker CLI (download.docker.com) - the `docker` the suite drives the engine with +# - the buildx CLI plugin - the runtime's Dockerfile needs BuildKit (`FROM --platform=$BUILDPLATFORM`), +# and Docker CLI >= 23 only builds with BuildKit through this plugin +# - Lima + Colima - Colima runs the Docker daemon inside a Linux VM on Apple's Virtualization.framework +# (`--vm-type vz`, macOS 13+), the engine most Docker-Desktop-less Mac developers use +# +# All of it lives under one directory, `$E2E_ENGINE_DIR` (default `$RUNNER_TEMP/actor-runtime-e2e-engine`): +# the binaries, Colima's state (`COLIMA_HOME`, which also places Lima's `LIMA_HOME` under it) and an +# isolated `DOCKER_CONFIG` for the docker context and the plugin. `teardown` deletes the VM and that +# directory, plus Lima's image download cache in `~/Library/Caches`, leaving the machine as it was found. +# The runner user's own `~/.docker`, `~/.colima` and `~/.lima` are never touched. +# +# Why no `DOCKER_HOST`: the suite mounts `hostEngineSocketPath()` into the runtime container, and with +# no `DOCKER_HOST` that is `/var/run/docker.sock` - which inside Colima's VM, where the container runs, +# IS the daemon's socket. Colima's host-side socket (`$COLIMA_HOME/default/docker.sock`) would not work +# there: a bind mount of a macOS unix socket does not cross into the VM. The CLI reaches the daemon +# through the docker context Colima activates (`--activate`, the default), which needs no env var. +# +# Usage: macos-docker-engine.sh install | teardown +# install downloads, verifies, starts the VM, smoke-tests the socket mount, and exports +# E2E_ENGINE_DIR / COLIMA_HOME / DOCKER_CONFIG and the PATH additions to the job +# (`$GITHUB_ENV` / `$GITHUB_PATH`) so every later step sees the same engine +# teardown stops and deletes the VM and removes everything `install` created; safe to run when +# `install` failed halfway or never ran +set -euo pipefail + +DOCKER_VERSION=29.8.1 +BUILDX_VERSION=v0.37.1 +LIMA_VERSION=2.2.0 +COLIMA_VERSION=v0.10.3 + +engine_dir=${E2E_ENGINE_DIR:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/actor-runtime-e2e-engine} +engine_dir=${engine_dir%/} +bin_dir=$engine_dir/bin +lima_bin_dir=$engine_dir/lima/bin +export COLIMA_HOME=$engine_dir/colima +export DOCKER_CONFIG=$engine_dir/docker-config +export PATH=$bin_dir:$lima_bin_dir:$PATH + +die() { + echo "::error::$*" >&2 + exit 1 +} + +fetch() { + # --retry covers the transient failures a GitHub release download sees; -f makes a 404 (a wrong + # pin) fail here, with the URL, rather than as a corrupt file three steps later. + curl -fsSL --retry 3 --retry-delay 2 -o "$2" "$1" || die "Download failed: $1" +} + +# verify +verify() { + local actual + actual=$(shasum -a 256 "$1" | cut -d' ' -f1) + [ "$actual" = "$2" ] || die "Checksum mismatch for $1: expected $2, got $actual" +} + +# sha_for : the hash a `sha256sum`-style listing gives for one file. +sha_for() { + awk -v name="$2" '{ f = $2; sub(/^\*/, "", f); if (f == name) { print $1; exit } }' "$1" +} + +install() { + [ "$(uname -s)" = Darwin ] || die "This script is for macOS; this is $(uname -s)." + [ "$(uname -m)" = arm64 ] || die "This script is for Apple Silicon (arm64); this is $(uname -m)." + local macos_major + macos_major=$(sw_vers -productVersion | cut -d. -f1) + [ "$macos_major" -ge 13 ] || die "Colima's vz driver needs macOS 13 or newer; this is $(sw_vers -productVersion)." + + rm -rf "$engine_dir" + mkdir -p "$bin_dir" "$DOCKER_CONFIG/cli-plugins" "$COLIMA_HOME" + local dl=$engine_dir/downloads + mkdir -p "$dl" + + echo "::group::Install Docker CLI $DOCKER_VERSION" + # The static bundle is served over TLS from Docker's own host and publishes no separate checksum; + # the daemon it will talk to is the pinned one Colima brings, not this tarball's concern. + fetch "https://download.docker.com/mac/static/stable/aarch64/docker-$DOCKER_VERSION.tgz" "$dl/docker.tgz" + tar -xzf "$dl/docker.tgz" -C "$dl" + install -m 0755 "$dl/docker/docker" "$bin_dir/docker" + echo "::endgroup::" + + echo "::group::Install buildx $BUILDX_VERSION" + local buildx_asset="buildx-$BUILDX_VERSION.darwin-arm64" + local buildx_base="https://github.com/docker/buildx/releases/download/$BUILDX_VERSION" + fetch "$buildx_base/$buildx_asset" "$dl/$buildx_asset" + fetch "$buildx_base/checksums.txt" "$dl/buildx-checksums.txt" + verify "$dl/$buildx_asset" "$(sha_for "$dl/buildx-checksums.txt" "$buildx_asset")" + install -m 0755 "$dl/$buildx_asset" "$DOCKER_CONFIG/cli-plugins/docker-buildx" + echo "::endgroup::" + + echo "::group::Install Lima $LIMA_VERSION" + local lima_asset="lima-$LIMA_VERSION-Darwin-arm64.tar.gz" + local lima_base="https://github.com/lima-vm/lima/releases/download/v$LIMA_VERSION" + fetch "$lima_base/$lima_asset" "$dl/$lima_asset" + fetch "$lima_base/SHA256SUMS" "$dl/lima-SHA256SUMS" + verify "$dl/$lima_asset" "$(sha_for "$dl/lima-SHA256SUMS" "$lima_asset")" + mkdir -p "$engine_dir/lima" + # The tarball is `bin/limactl`, `share/lima/...` at its root; `limactl` is ad-hoc signed with the + # Virtualization entitlement by Lima's release build, which a curl download keeps (no quarantine). + tar -xzf "$dl/$lima_asset" -C "$engine_dir/lima" + echo "::endgroup::" + + echo "::group::Install Colima $COLIMA_VERSION" + local colima_asset=colima-Darwin-arm64 + local colima_base="https://github.com/abiosoft/colima/releases/download/$COLIMA_VERSION" + fetch "$colima_base/$colima_asset" "$dl/$colima_asset" + fetch "$colima_base/$colima_asset.sha256sum" "$dl/$colima_asset.sha256sum" + verify "$dl/$colima_asset" "$(cut -d' ' -f1 "$dl/$colima_asset.sha256sum")" + install -m 0755 "$dl/$colima_asset" "$bin_dir/colima" + echo "::endgroup::" + + rm -rf "$dl" + + echo "::group::Start the Docker VM" + # Half the machine, with a floor: the runtime image build (tsc, pip) and two Actor builds run in here, + # and Colima's own defaults (2 CPUs, 2 GiB) are tight for that. + local ncpu memsize cpus memory + ncpu=$(sysctl -n hw.ncpu) + memsize=$(sysctl -n hw.memsize) + cpus=$((ncpu / 2)) + [ "$cpus" -ge 2 ] || cpus=2 + memory=$((memsize / 1024 / 1024 / 1024 / 2)) + [ "$memory" -ge 4 ] || memory=4 + echo "VM: $cpus CPUs, ${memory} GiB memory (host: $ncpu CPUs, $((memsize / 1024 / 1024 / 1024)) GiB)" + # `--disk` is a sparse maximum, not an allocation. No `--vz-rosetta`: nothing in the dev-loop file + # needs amd64 emulation; it is the switch to flip when the browser-view files join this leg. No + # extra `--mount`: the dev-loop file bind-mounts nothing from the host (the runtime container's + # `/data` is a named volume) - the dev-folder files would need `$TMPDIR` mounted writable. + colima start \ + --runtime docker \ + --vm-type vz \ + --mount-type virtiofs \ + --arch aarch64 \ + --cpu "$cpus" \ + --memory "$memory" \ + --disk 60 \ + --activate \ + --verbose + echo "::endgroup::" + + echo "::group::Check the engine" + colima status + [ "$(docker context show)" = colima ] || die "Colima did not activate its docker context (current: $(docker context show))." + docker version + docker buildx version + docker info --format 'Arch={{.Architecture}} OS={{.OperatingSystem}} Server={{.ServerVersion}}' + # The exact mount the suite gives the runtime container - proven before the suite spends minutes + # building the image, so an engine that cannot do this fails here, with this message. + docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker.io/library/busybox \ + test -S /var/run/docker.sock || + die "/var/run/docker.sock is not a socket inside a container on this engine; the runtime container needs it to be." + echo "::endgroup::" + + if [ -n "${GITHUB_ENV:-}" ]; then + { + echo "E2E_ENGINE_DIR=$engine_dir" + echo "COLIMA_HOME=$COLIMA_HOME" + echo "DOCKER_CONFIG=$DOCKER_CONFIG" + } >> "$GITHUB_ENV" + fi + if [ -n "${GITHUB_PATH:-}" ]; then + printf '%s\n' "$bin_dir" "$lima_bin_dir" >> "$GITHUB_PATH" + fi + echo "Docker engine ready under $engine_dir." +} + +teardown() { + if command -v colima >/dev/null 2>&1 && [ -d "$COLIMA_HOME" ]; then + colima stop --force 2>/dev/null || true + colima delete --force 2>/dev/null || true + fi + # Lima's host agent and the ssh sessions it keeps to the VM, matched by their own names plus Colima's + # state dir on their command line - Colima normally ends them; this is for a half-started VM. Not a + # bare `pkill -f "$engine_dir"`: that also matches any shell whose command line mentions the dir. + pkill -f "limactl.*$COLIMA_HOME" 2>/dev/null || true + pkill -f "ssh.*$COLIMA_HOME" 2>/dev/null || true + rm -rf "$engine_dir" + # Lima keeps the downloaded VM images here, outside LIMA_HOME. Removed for a fully clean machine; a + # runner that stays up between jobs could keep it and skip the ~700 MB download next time. + rm -rf "$HOME/Library/Caches/lima" "$HOME/Library/Caches/colima" + echo "Docker engine removed." +} + +case "${1:-}" in + install) install ;; + teardown) teardown ;; + *) + echo "Usage: $0 install | teardown" >&2 + exit 2 + ;; +esac diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1685bb43..7074963e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,32 +115,31 @@ jobs: e2e-macos-arm64: name: e2e actor-dev-loop (apify-cli + docker on macOS arm64, self-hosted) - # The one leg on the platform most developers actually run the runtime on: Docker Desktop on an - # Apple Silicon Mac, where the engine lives in a VM and the socket the runtime container gets - # mounted (`/var/run/docker.sock`) is Docker Desktop's forwarded one, not a kernel socket. It is a - # persistent self-hosted machine, so unlike every hosted leg above it is cleaned before and after. + # The one leg on the platform most developers run the runtime on: an Apple Silicon Mac, with the + # engine in a Linux VM and the socket the runtime container gets mounted (`/var/run/docker.sock`) + # being the VM's, not a kernel socket of the host. The runner is a clean Mac - no Docker, Podman + # or OrbStack - so the job brings its own engine (Colima + the Docker CLI, user space, no sudo; + # `.github/scripts/macos-docker-engine.sh`) and removes it again when done. It is a persistent + # self-hosted machine, so unlike every hosted leg above it is also cleaned before and after. # # Self-hosted, so never for a fork's pull request: `pull_request` runs the head branch's code, # and the runner is a long-lived Apify machine. Pushes to master and same-repo PRs only. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: [self-hosted, macOS, ARM64] - timeout-minutes: 30 + timeout-minutes: 45 steps: - uses: actions/checkout@v4 - # A previous job on this machine may have been cancelled or timed out mid-suite, leaving the - # runtime container holding 3333/3000 and Actor containers running. Fails if the ports are - # held by something that is not ours, rather than letting `docker run` fail deep in `beforeAll`. + # Downloads pinned Docker CLI, buildx, Lima and Colima into the job's temp dir, starts the VM + # and smoke-tests the socket mount the suite relies on. Exports the engine's env to the job; + # it deliberately does not set `DOCKER_HOST` (see the script for why). + - name: Install a Docker engine (Colima, user space) + run: .github/scripts/macos-docker-engine.sh install + # A previous job on this machine may have been cancelled or timed out mid-suite. The engine is + # new, so there is nothing of ours left in it; this catches the suite's temp dirs and, above + # all, fails with the holder named if 3333/3000 is taken by something that is not ours, rather + # than letting `docker run` fail deep in `beforeAll`. - name: Clean up leftovers of earlier runs run: .github/scripts/e2e-host-cleanup.sh --require-free-ports - # What the suite is about to talk to, for when a failure is the machine's setup rather than - # the code: the engine, its context, and the architecture it reports. - - name: Show container engine - run: | - uname -m - docker context show - docker version - docker info --format 'Arch={{.Architecture}} OS={{.OperatingSystem}} Server={{.ServerVersion}}' - ls -l /var/run/docker.sock - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -149,8 +148,8 @@ jobs: - run: pnpm install --frozen-lockfile # Only this file for now - the smallest end-to-end exercise of the whole chain (build the # runtime image, run it, push/build/call two Actors through apify-cli) on this executor. The - # browser-view and all-modes files are the candidates to add next: Docker Desktop on Apple - # Silicon emulates amd64 out of the box, which is what their fallback builds need. + # browser-view and all-modes files are the candidates to add next; their fallback builds need + # amd64 emulation, which is Colima's `--vz-rosetta` switch in the install script. - run: pnpm exec vitest run test/e2e/actor-dev-loop.test.ts - name: Dump runtime container logs on failure if: failure() @@ -158,8 +157,13 @@ jobs: for c in $(docker ps -aq --filter name=actor-runtime-e2e); do docker logs --tail 300 "$c" || true done - # Always, so the next job starts clean whatever happened here - and so the suite's built - # images and temp dirs do not accumulate on a machine that is never recreated. + # Always, so the next job starts clean whatever happened here. The engine and everything in + # it goes in the next step anyway; this one is for the suite's temp dirs on the host. - name: Clean up if: always() run: .github/scripts/e2e-host-cleanup.sh + # Stops and deletes the VM and removes the binaries, Colima's state, the isolated docker config + # and Lima's image cache - the machine is left as the job found it. + - name: Remove the Docker engine + if: always() + run: .github/scripts/macos-docker-engine.sh teardown diff --git a/requirements/test.md b/requirements/test.md index c710435b..17519d3b 100644 --- a/requirements/test.md +++ b/requirements/test.md @@ -6,7 +6,7 @@ - CI (GitHub Actions) runs on every pull request and on pushes to the main branches: build, lint, format check, and all test layers, with the mandatory CLI-only e2e suite below executing against a real Docker daemon. A missing daemon fails the CI job - the e2e suite never silently skips. - CI runs each e2e file as its own job, in parallel; locally the files run one after another (each starts a runtime container on the fixed ports). -- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the Actor dev-loop e2e file on a self-hosted macOS arm64 runner with Docker Desktop - the setup most developers use the runtime from. That runner is a persistent machine, so the job removes everything the suite leaves behind (its runtime container and volume, the Actor containers and images the runtime built, its temp dirs) both before and after the test, and it never runs for pull requests from forks. +- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the Actor dev-loop e2e file on a self-hosted macOS arm64 runner - the platform most developers use the runtime from. That runner is a clean, persistent Mac with no container engine of its own, so the job installs one for itself in user space (Colima's Docker VM plus the Docker CLI, pinned and checksum-verified, no sudo), runs the test, then removes everything the suite left behind (its temp dirs, and the engine with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. # Mandatory end-to-end tests From 9c42b0bba7daa9158df7416307224124d009eae1 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 13:19:51 +0000 Subject: [PATCH 03/21] fix(ci): Stop the macOS engine script recursing into its own `install` The script's entry-point function was named `install`, which in bash shadows /usr/bin/install - so the `install -m 0755` copies inside it called the function again, re-downloading the Docker CLI on every round until the shell ran out of stack ("Segmentation fault: 11" on the runner). The functions are now `engine_install` / `engine_teardown`, and the copies say `command install` so a future rename cannot bring this back. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-docker-engine.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/scripts/macos-docker-engine.sh b/.github/scripts/macos-docker-engine.sh index 86cc044b..cb6d0881 100755 --- a/.github/scripts/macos-docker-engine.sh +++ b/.github/scripts/macos-docker-engine.sh @@ -31,6 +31,10 @@ # `install` failed halfway or never ran set -euo pipefail +# The two entry points are `engine_install` / `engine_teardown`, not `install` / `teardown`: a bash +# function named `install` shadows /usr/bin/install, so the `install -m 0755` copies below would call +# the function itself - endless recursion, re-downloading on every round, until bash segfaults. The +# copies also say `command install` so that can never come back with a rename. DOCKER_VERSION=29.8.1 BUILDX_VERSION=v0.37.1 LIMA_VERSION=2.2.0 @@ -67,7 +71,7 @@ sha_for() { awk -v name="$2" '{ f = $2; sub(/^\*/, "", f); if (f == name) { print $1; exit } }' "$1" } -install() { +engine_install() { [ "$(uname -s)" = Darwin ] || die "This script is for macOS; this is $(uname -s)." [ "$(uname -m)" = arm64 ] || die "This script is for Apple Silicon (arm64); this is $(uname -m)." local macos_major @@ -84,7 +88,7 @@ install() { # the daemon it will talk to is the pinned one Colima brings, not this tarball's concern. fetch "https://download.docker.com/mac/static/stable/aarch64/docker-$DOCKER_VERSION.tgz" "$dl/docker.tgz" tar -xzf "$dl/docker.tgz" -C "$dl" - install -m 0755 "$dl/docker/docker" "$bin_dir/docker" + command install -m 0755 "$dl/docker/docker" "$bin_dir/docker" echo "::endgroup::" echo "::group::Install buildx $BUILDX_VERSION" @@ -93,7 +97,7 @@ install() { fetch "$buildx_base/$buildx_asset" "$dl/$buildx_asset" fetch "$buildx_base/checksums.txt" "$dl/buildx-checksums.txt" verify "$dl/$buildx_asset" "$(sha_for "$dl/buildx-checksums.txt" "$buildx_asset")" - install -m 0755 "$dl/$buildx_asset" "$DOCKER_CONFIG/cli-plugins/docker-buildx" + command install -m 0755 "$dl/$buildx_asset" "$DOCKER_CONFIG/cli-plugins/docker-buildx" echo "::endgroup::" echo "::group::Install Lima $LIMA_VERSION" @@ -114,7 +118,7 @@ install() { fetch "$colima_base/$colima_asset" "$dl/$colima_asset" fetch "$colima_base/$colima_asset.sha256sum" "$dl/$colima_asset.sha256sum" verify "$dl/$colima_asset" "$(cut -d' ' -f1 "$dl/$colima_asset.sha256sum")" - install -m 0755 "$dl/$colima_asset" "$bin_dir/colima" + command install -m 0755 "$dl/$colima_asset" "$bin_dir/colima" echo "::endgroup::" rm -rf "$dl" @@ -172,7 +176,7 @@ install() { echo "Docker engine ready under $engine_dir." } -teardown() { +engine_teardown() { if command -v colima >/dev/null 2>&1 && [ -d "$COLIMA_HOME" ]; then colima stop --force 2>/dev/null || true colima delete --force 2>/dev/null || true @@ -190,8 +194,8 @@ teardown() { } case "${1:-}" in - install) install ;; - teardown) teardown ;; + install) engine_install ;; + teardown) engine_teardown ;; *) echo "Usage: $0 install | teardown" >&2 exit 2 From 1f4491cc18ff0cdcc5c9fe57a174be23d7b585e9 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 13:24:08 +0000 Subject: [PATCH 04/21] fix(ci): Verify the buildx darwin binary against `checksums-signed.txt` buildx's `checksums.txt` lists no darwin (or windows) binaries - those are code-signed after the list is written and land in `checksums-signed.txt` instead - so the macOS engine script looked up an empty checksum and failed with "expected , got c3cb...". Read the signed list for buildx, and make `verify` say plainly when an asset is missing from a checksum list rather than reporting an empty expected value. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-docker-engine.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/scripts/macos-docker-engine.sh b/.github/scripts/macos-docker-engine.sh index cb6d0881..e5f29f85 100755 --- a/.github/scripts/macos-docker-engine.sh +++ b/.github/scripts/macos-docker-engine.sh @@ -61,6 +61,7 @@ fetch() { # verify verify() { + [ -n "$2" ] || die "No published checksum found for $1 - the release's checksum list does not name this asset." local actual actual=$(shasum -a 256 "$1" | cut -d' ' -f1) [ "$actual" = "$2" ] || die "Checksum mismatch for $1: expected $2, got $actual" @@ -95,7 +96,9 @@ engine_install() { local buildx_asset="buildx-$BUILDX_VERSION.darwin-arm64" local buildx_base="https://github.com/docker/buildx/releases/download/$BUILDX_VERSION" fetch "$buildx_base/$buildx_asset" "$dl/$buildx_asset" - fetch "$buildx_base/checksums.txt" "$dl/buildx-checksums.txt" + # `checksums-signed.txt`, not `checksums.txt`: buildx code-signs its darwin and windows binaries after + # the main list is written, so only the signed list names them. + fetch "$buildx_base/checksums-signed.txt" "$dl/buildx-checksums.txt" verify "$dl/$buildx_asset" "$(sha_for "$dl/buildx-checksums.txt" "$buildx_asset")" command install -m 0755 "$dl/$buildx_asset" "$DOCKER_CONFIG/cli-plugins/docker-buildx" echo "::endgroup::" From f48ef54d8714a040bd55b133ad7193f9a8ed4872 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 13:26:39 +0000 Subject: [PATCH 05/21] fix(ci): Keep the macOS engine's state dir short enough for Lima's sockets Lima puts unix sockets in the instance directory, and macOS caps a socket path at 104 characters; under the runner's `_work/_temp/...` the path came to 112 and `colima start` died with "instance name `colima` too long". The engine now lives in `$HOME/.actor-runtime-e2e` by default, which teardown removes as before. Also export the engine's env and PATH to the job before starting the VM, so the workflow's log-dump and teardown steps find `docker` and `colima` even when the start fails. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-docker-engine.sh | 41 +++++++++++++++----------- .github/workflows/ci.yml | 7 +++-- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/.github/scripts/macos-docker-engine.sh b/.github/scripts/macos-docker-engine.sh index e5f29f85..bb8ad21b 100755 --- a/.github/scripts/macos-docker-engine.sh +++ b/.github/scripts/macos-docker-engine.sh @@ -11,11 +11,16 @@ # - Lima + Colima - Colima runs the Docker daemon inside a Linux VM on Apple's Virtualization.framework # (`--vm-type vz`, macOS 13+), the engine most Docker-Desktop-less Mac developers use # -# All of it lives under one directory, `$E2E_ENGINE_DIR` (default `$RUNNER_TEMP/actor-runtime-e2e-engine`): -# the binaries, Colima's state (`COLIMA_HOME`, which also places Lima's `LIMA_HOME` under it) and an -# isolated `DOCKER_CONFIG` for the docker context and the plugin. `teardown` deletes the VM and that -# directory, plus Lima's image download cache in `~/Library/Caches`, leaving the machine as it was found. -# The runner user's own `~/.docker`, `~/.colima` and `~/.lima` are never touched. +# All of it lives under one directory, `$E2E_ENGINE_DIR` (default `$HOME/.actor-runtime-e2e`): the +# binaries, Colima's state (`COLIMA_HOME`, which also places Lima's `LIMA_HOME` under it) and an isolated +# `DOCKER_CONFIG` for the docker context and the plugin. `teardown` deletes the VM and that directory, +# plus Lima's image download cache in `~/Library/Caches`, leaving the machine as it was found. The +# runner user's own `~/.docker`, `~/.colima` and `~/.lima` are never touched. +# +# The directory is deliberately short and directly under `$HOME`, not under `$RUNNER_TEMP`: Lima puts +# unix sockets in the instance dir (`/colima/ssh.sock.<16 digits>`), and macOS caps a +# socket path at 104 characters - the runner's `_work/_temp/...` path pushed that to 112 and Lima +# refused to start. # # Why no `DOCKER_HOST`: the suite mounts `hostEngineSocketPath()` into the runtime container, and with # no `DOCKER_HOST` that is `/var/run/docker.sock` - which inside Colima's VM, where the container runs, @@ -40,7 +45,7 @@ BUILDX_VERSION=v0.37.1 LIMA_VERSION=2.2.0 COLIMA_VERSION=v0.10.3 -engine_dir=${E2E_ENGINE_DIR:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/actor-runtime-e2e-engine} +engine_dir=${E2E_ENGINE_DIR:-$HOME/.actor-runtime-e2e} engine_dir=${engine_dir%/} bin_dir=$engine_dir/bin lima_bin_dir=$engine_dir/lima/bin @@ -126,6 +131,19 @@ engine_install() { rm -rf "$dl" + # Exported before the VM start, not after: the workflow's later steps (the log dump on failure, + # `teardown`) need `docker` and `colima` on their PATH even when the start below fails. + if [ -n "${GITHUB_ENV:-}" ]; then + { + echo "E2E_ENGINE_DIR=$engine_dir" + echo "COLIMA_HOME=$COLIMA_HOME" + echo "DOCKER_CONFIG=$DOCKER_CONFIG" + } >> "$GITHUB_ENV" + fi + if [ -n "${GITHUB_PATH:-}" ]; then + printf '%s\n' "$bin_dir" "$lima_bin_dir" >> "$GITHUB_PATH" + fi + echo "::group::Start the Docker VM" # Half the machine, with a floor: the runtime image build (tsc, pip) and two Actor builds run in here, # and Colima's own defaults (2 CPUs, 2 GiB) are tight for that. @@ -165,17 +183,6 @@ engine_install() { test -S /var/run/docker.sock || die "/var/run/docker.sock is not a socket inside a container on this engine; the runtime container needs it to be." echo "::endgroup::" - - if [ -n "${GITHUB_ENV:-}" ]; then - { - echo "E2E_ENGINE_DIR=$engine_dir" - echo "COLIMA_HOME=$COLIMA_HOME" - echo "DOCKER_CONFIG=$DOCKER_CONFIG" - } >> "$GITHUB_ENV" - fi - if [ -n "${GITHUB_PATH:-}" ]; then - printf '%s\n' "$bin_dir" "$lima_bin_dir" >> "$GITHUB_PATH" - fi echo "Docker engine ready under $engine_dir." } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7074963e..2b555204 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,9 +129,10 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@v4 - # Downloads pinned Docker CLI, buildx, Lima and Colima into the job's temp dir, starts the VM - # and smoke-tests the socket mount the suite relies on. Exports the engine's env to the job; - # it deliberately does not set `DOCKER_HOST` (see the script for why). + # Downloads pinned Docker CLI, buildx, Lima and Colima into one directory under the runner + # user's home (removed again in the last step), starts the VM and smoke-tests the socket mount + # the suite relies on. Exports the engine's env to the job; it deliberately does not set + # `DOCKER_HOST` (see the script for why). - name: Install a Docker engine (Colima, user space) run: .github/scripts/macos-docker-engine.sh install # A previous job on this machine may have been cancelled or timed out mid-suite. The engine is From 479440be05007ca7eb53d1fea884662c324e2d60 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 13:41:51 +0000 Subject: [PATCH 06/21] ci: Run the whole e2e suite on the macOS runner, against Docker and Podman The macOS leg now runs every e2e file - browser view and the live dev folder included - once against Docker and once against Podman, as a two-engine matrix with one job per engine running `test:e2e` (the files are serialized on the fixed host ports, and there is one runner anyway). `macos-docker-engine.sh` becomes `macos-container-engine.sh` with `install docker|podman`: - docker: Colima + Docker CLI + buildx, as before - podman: the podman remote client against Lima's own `podman-rootful` template (Fedora's Podman, its socket at `/run/podman/podman.sock` in the VM - the suite's default mount path for Podman, so no `DOCKER_HOST`; the client is pointed at the forwarded socket via `CONTAINER_HOST`) Both VMs share the runner user's `$HOME` writable at the same path, and the job's `TMPDIR` is pointed under it: the dev-folder files register temp folders the runtime probes and bind-mounts through the daemon, so they must exist inside the VM. Rosetta is used for the amd64 Playwright images when the host has it (installation is attempted once), with QEMU user-mode emulation registered in the VM as the fallback. The install smoke-tests the socket mount, a `$HOME` bind mount and amd64 execution before the suite runs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/e2e-host-cleanup.sh | 2 +- .github/scripts/macos-container-engine.sh | 383 ++++++++++++++++++++++ .github/scripts/macos-docker-engine.sh | 213 ------------ .github/workflows/ci.yml | 65 ++-- requirements/test.md | 2 +- 5 files changed, 423 insertions(+), 242 deletions(-) create mode 100755 .github/scripts/macos-container-engine.sh delete mode 100755 .github/scripts/macos-docker-engine.sh diff --git a/.github/scripts/e2e-host-cleanup.sh b/.github/scripts/e2e-host-cleanup.sh index 0abb8105..fa25cc26 100755 --- a/.github/scripts/e2e-host-cleanup.sh +++ b/.github/scripts/e2e-host-cleanup.sh @@ -3,7 +3,7 @@ # self-hosted macOS arm64 one in `ci.yml` - starts each job clean and leaves it clean. The hosted # runners are ephemeral and never need this; there `stopRuntimeContainer`'s `afterAll` is enough, # and the "Dump runtime container logs on failure" step relies on the container still being there. -# On the macOS leg the engine itself is per-job (`macos-docker-engine.sh`), so the engine-side part +# On the macOS leg the engine itself is per-job (`macos-container-engine.sh`), so the engine-side part # below finds little to do there; the temp dirs and the port check are what it is for on that runner. # # Scoped to what the suite and the runtime it drives create, nothing else on the machine: diff --git a/.github/scripts/macos-container-engine.sh b/.github/scripts/macos-container-engine.sh new file mode 100755 index 00000000..b3644144 --- /dev/null +++ b/.github/scripts/macos-container-engine.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash +# Puts a container engine on a clean macOS arm64 host for the e2e suite, and takes it away again - +# for the self-hosted runner in `ci.yml`, which has no Docker, Podman or OrbStack of its own. +# +# Everything is user space: no sudo, no Homebrew, no GUI login session, nothing under /usr/local or +# /Applications. The pieces, all pinned, all downloaded straight from their projects' releases and +# verified against the checksums those releases publish: +# - Lima - runs a Linux VM on Apple's Virtualization.framework (`vz`, macOS 13+); both engines live in +# one such VM, which is also how Docker Desktop, OrbStack and Podman Desktop work on a Mac +# - `docker`: the static Docker CLI (download.docker.com), the buildx CLI plugin (the runtime's +# Dockerfile needs BuildKit: `FROM --platform=$BUILDPLATFORM`, and Docker CLI >= 23 only builds with +# BuildKit through this plugin), and Colima, which provisions the Docker daemon in the Lima VM +# - `podman`: the podman remote client (containers/podman's `podman-remote-release-darwin_arm64.zip`) +# talking to Lima's own `podman-rootful` template - Fedora's current Podman, its API socket at +# `/run/podman/podman.sock` inside the VM, forwarded to a host socket the client is pointed at +# +# All of it lives under one directory, `$E2E_ENGINE_DIR` (default `$HOME/.actor-runtime-e2e`): the +# binaries, Colima's state (`COLIMA_HOME`, which also places Lima's `LIMA_HOME` under it), the Podman +# instance's `LIMA_HOME`, an isolated `DOCKER_CONFIG` for the docker context and the plugin, and a +# `tmp` dir the job's `TMPDIR` is pointed at (below). `teardown` deletes the VM and that directory, +# plus Lima's image download cache in `~/Library/Caches`, leaving the machine as it was found. The +# runner user's own `~/.docker`, `~/.colima`, `~/.lima` and `~/.config/containers` are never touched. +# +# The directory is deliberately short and directly under `$HOME`, not under `$RUNNER_TEMP`: Lima puts +# unix sockets in the instance dir (`//ssh.sock.<16 digits>`), and macOS caps a +# socket path at 104 characters - the runner's `_work/_temp/...` path pushed that to 112 and Lima +# refused to start. +# +# Host directories inside the VM: `$HOME` is mounted, writable, at the same path (virtiofs) - the way +# Docker Desktop shares `/Users` by default. The dev-folder e2e files need it: the runtime probes the +# registered folder through the daemon's view of `/` and bind-mounts it into the Actor container, so +# the path must exist inside the VM. `TMPDIR` is pointed at `$engine_dir/tmp` for the same reason: +# the suite's `mkdtemp` folders (Node's `os.tmpdir()` honors `TMPDIR`) have to be under the mount, +# and macOS's per-user default (`/var/folders/...`) is not. +# +# amd64 emulation: the Playwright sample Actors' base images have no arm64 build, so the browser-view +# files build and run them as amd64 (`build-platform.ts`'s fallback). With Rosetta on the host the VM +# gets it (`--vz-rosetta` / `--rosetta`), which is fast; without it the fallback is QEMU user-mode +# emulation registered in the VM (`tonistiigi/binfmt` for Docker, `qemu-user-static` for Podman), +# which is slow but works. Installing Rosetta is attempted once; it may need admin rights. +# +# Why no `DOCKER_HOST`: the suite mounts `hostEngineSocketPath()` into the runtime container, and with +# no `DOCKER_HOST` that is `/var/run/docker.sock` (docker) or `/run/podman/podman.sock` (podman) - +# which inside the VM, where the container runs, IS the daemon's socket. The host-side forwarded +# sockets would not work there: a bind mount of a macOS unix socket does not cross into the VM. The +# CLIs reach the daemon another way: the docker context Colima activates (`--activate`, the default), +# and `CONTAINER_HOST` for the podman remote client - neither is read by the suite. +# +# Usage: macos-container-engine.sh install docker|podman +# macos-container-engine.sh teardown +# install downloads, verifies, starts the VM, smoke-tests the socket and home mounts, and exports +# the engine's env (`CONTAINER_CLI`, `TMPDIR`, ...) and PATH additions to the job +# (`$GITHUB_ENV` / `$GITHUB_PATH`) so every later step sees the same engine +# teardown stops and deletes whichever VM exists and removes everything `install` created; safe to +# run when `install` failed halfway or never ran +set -euo pipefail + +# The two entry points are `engine_install` / `engine_teardown`, not `install` / `teardown`: a bash +# function named `install` shadows /usr/bin/install, so the `install -m 0755` copies below would call +# the function itself - endless recursion, re-downloading on every round, until bash segfaults. The +# copies also say `command install` so that can never come back with a rename. +DOCKER_VERSION=29.8.1 +BUILDX_VERSION=v0.37.1 +LIMA_VERSION=2.2.0 +COLIMA_VERSION=v0.10.3 +PODMAN_VERSION=v6.1.2 + +engine_dir=${E2E_ENGINE_DIR:-$HOME/.actor-runtime-e2e} +engine_dir=${engine_dir%/} +bin_dir=$engine_dir/bin +lima_bin_dir=$engine_dir/lima/bin +tmp_dir=$engine_dir/tmp +export COLIMA_HOME=$engine_dir/colima +export DOCKER_CONFIG=$engine_dir/docker-config +# Lima home for the standalone (podman) instance. Colima ignores it and uses `$COLIMA_HOME/_lima`. +podman_lima_home=$engine_dir/limahome +PODMAN_INSTANCE=podman +export PATH=$bin_dir:$lima_bin_dir:$PATH + +die() { + echo "::error::$*" >&2 + exit 1 +} + +warn() { + echo "::warning::$*" >&2 +} + +fetch() { + # --retry covers the transient failures a GitHub release download sees; -f makes a 404 (a wrong + # pin) fail here, with the URL, rather than as a corrupt file three steps later. + curl -fsSL --retry 3 --retry-delay 2 -o "$2" "$1" || die "Download failed: $1" +} + +# verify +verify() { + [ -n "$2" ] || die "No published checksum found for $1 - the release's checksum list does not name this asset." + local actual + actual=$(shasum -a 256 "$1" | cut -d' ' -f1) + [ "$actual" = "$2" ] || die "Checksum mismatch for $1: expected $2, got $actual" +} + +# sha_for : the hash a `sha256sum`-style listing gives for one file. +sha_for() { + awk -v name="$2" '{ f = $2; sub(/^\*/, "", f); if (f == name) { print $1; exit } }' "$1" +} + +# Half the machine, with a floor: the runtime image build (tsc, pip), Actor builds and emulated +# browsers run in here, and the engines' own defaults (2 CPUs, 2-4 GiB) are tight for that. +vm_cpus=2 +vm_memory=4 +vm_size() { + local ncpu memsize + ncpu=$(sysctl -n hw.ncpu) + memsize=$(sysctl -n hw.memsize) + vm_cpus=$((ncpu / 2)) + [ "$vm_cpus" -ge 2 ] || vm_cpus=2 + vm_memory=$((memsize / 1024 / 1024 / 1024 / 2)) + [ "$vm_memory" -ge 4 ] || vm_memory=4 + echo "VM: $vm_cpus CPUs, ${vm_memory} GiB memory (host: $ncpu CPUs, $((memsize / 1024 / 1024 / 1024)) GiB)" +} + +# `arch -x86_64` can only run a binary when Rosetta is installed - the most direct test there is. +rosetta_available() { + /usr/bin/arch -x86_64 /usr/bin/true 2>/dev/null +} + +# Sets `rosetta=1` when Rosetta is (or could just now be) installed, `rosetta=` otherwise. +rosetta= +ensure_rosetta() { + if rosetta_available; then + echo "Rosetta is installed; the VM gets it for amd64 containers." + rosetta=1 + return 0 + fi + echo "Rosetta is not installed; trying to install it (this may need admin rights and then fail)..." + if /usr/sbin/softwareupdate --install-rosetta --agree-to-license >/dev/null 2>&1 && rosetta_available; then + echo "Rosetta installed." + rosetta=1 + return 0 + fi + warn "Rosetta is not available on this Mac; amd64 Actor images (the Playwright samples) will run under QEMU emulation, which is much slower." + rosetta= +} + +# smoke_tests : the exact things the suite relies on, proven +# before it spends minutes building images, so an engine that cannot do them fails here, with a +# message that says which. +smoke_tests() { + local cli=$1 sock=$2 + echo "::group::Check the engine" + "$cli" version + "$cli" info --format 'Arch={{.Host.Arch}} OS={{.Host.Distribution.Distribution}}' 2>/dev/null || + "$cli" info --format 'Arch={{.Architecture}} OS={{.OperatingSystem}} Server={{.ServerVersion}}' + # The mount the runtime container gets: the daemon's own socket, reachable from inside a container. + "$cli" run --rm -v "$sock:/var/run/docker.sock" docker.io/library/busybox test -S /var/run/docker.sock || + die "$sock is not a socket inside a container on this engine; the runtime container needs it to be." + # A host directory under $HOME, bind-mounted by its host path - what the dev-folder files do. + mkdir -p "$tmp_dir" + local marker="$tmp_dir/.mount-probe" + echo ok > "$marker" + "$cli" run --rm -v "$tmp_dir:/probe" docker.io/library/busybox cat /probe/.mount-probe >/dev/null || + die "$tmp_dir is not visible inside the VM by its host path; the dev-folder e2e files need \$HOME shared into the VM." + rm -f "$marker" + # amd64 emulation, for the browser-view files. A warning, not a failure: only those files need it, + # and their own assertions will say so if it is missing. + if "$cli" run --rm --platform linux/amd64 docker.io/library/busybox uname -m 2>/dev/null | grep -q x86_64; then + echo "amd64 containers run on this engine." + else + warn "amd64 containers do not run on this engine; the browser-view e2e files will fail." + fi + echo "::endgroup::" +} + +export_env() { + # Exported before the VM start, not after: the workflow's later steps (the log dump on failure, + # `teardown`) need the CLIs on their PATH even when the start fails. + mkdir -p "$tmp_dir" + if [ -n "${GITHUB_ENV:-}" ]; then + { + echo "E2E_ENGINE_DIR=$engine_dir" + echo "COLIMA_HOME=$COLIMA_HOME" + echo "DOCKER_CONFIG=$DOCKER_CONFIG" + echo "TMPDIR=$tmp_dir" + printf '%s\n' "$@" + } >> "$GITHUB_ENV" + fi + if [ -n "${GITHUB_PATH:-}" ]; then + printf '%s\n' "$bin_dir" "$lima_bin_dir" >> "$GITHUB_PATH" + fi +} + +install_lima() { + echo "::group::Install Lima $LIMA_VERSION" + local dl=$1 + local lima_asset="lima-$LIMA_VERSION-Darwin-arm64.tar.gz" + local lima_base="https://github.com/lima-vm/lima/releases/download/v$LIMA_VERSION" + fetch "$lima_base/$lima_asset" "$dl/$lima_asset" + fetch "$lima_base/SHA256SUMS" "$dl/lima-SHA256SUMS" + verify "$dl/$lima_asset" "$(sha_for "$dl/lima-SHA256SUMS" "$lima_asset")" + mkdir -p "$engine_dir/lima" + # The tarball is `bin/limactl`, `share/lima/...` at its root; `limactl` is ad-hoc signed with the + # Virtualization entitlement by Lima's release build, which a curl download keeps (no quarantine). + tar -xzf "$dl/$lima_asset" -C "$engine_dir/lima" + echo "::endgroup::" +} + +install_docker() { + local dl=$1 + + echo "::group::Install Docker CLI $DOCKER_VERSION" + # The static bundle is served over TLS from Docker's own host and publishes no separate checksum; + # the daemon it will talk to is the pinned one Colima brings, not this tarball's concern. + fetch "https://download.docker.com/mac/static/stable/aarch64/docker-$DOCKER_VERSION.tgz" "$dl/docker.tgz" + tar -xzf "$dl/docker.tgz" -C "$dl" + command install -m 0755 "$dl/docker/docker" "$bin_dir/docker" + echo "::endgroup::" + + echo "::group::Install buildx $BUILDX_VERSION" + local buildx_asset="buildx-$BUILDX_VERSION.darwin-arm64" + local buildx_base="https://github.com/docker/buildx/releases/download/$BUILDX_VERSION" + fetch "$buildx_base/$buildx_asset" "$dl/$buildx_asset" + # `checksums-signed.txt`, not `checksums.txt`: buildx code-signs its darwin and windows binaries after + # the main list is written, so only the signed list names them. + fetch "$buildx_base/checksums-signed.txt" "$dl/buildx-checksums.txt" + verify "$dl/$buildx_asset" "$(sha_for "$dl/buildx-checksums.txt" "$buildx_asset")" + command install -m 0755 "$dl/$buildx_asset" "$DOCKER_CONFIG/cli-plugins/docker-buildx" + echo "::endgroup::" + + echo "::group::Install Colima $COLIMA_VERSION" + local colima_asset=colima-Darwin-arm64 + local colima_base="https://github.com/abiosoft/colima/releases/download/$COLIMA_VERSION" + fetch "$colima_base/$colima_asset" "$dl/$colima_asset" + fetch "$colima_base/$colima_asset.sha256sum" "$dl/$colima_asset.sha256sum" + verify "$dl/$colima_asset" "$(cut -d' ' -f1 "$dl/$colima_asset.sha256sum")" + command install -m 0755 "$dl/$colima_asset" "$bin_dir/colima" + echo "::endgroup::" + + rm -rf "$dl" + export_env "CONTAINER_CLI=docker" + + echo "::group::Start the Docker VM" + vm_size + ensure_rosetta || true + # `--disk` is a sparse maximum, not an allocation. `--mount` replaces Colima's default mounts + # (`$HOME` read-only-ish and `/tmp/colima`) with the one the suite needs, writable. + colima start \ + --runtime docker \ + --vm-type vz \ + --mount-type virtiofs \ + --arch aarch64 \ + --cpu "$vm_cpus" \ + --memory "$vm_memory" \ + --disk 60 \ + --mount "$HOME:w" \ + ${rosetta:+--vz-rosetta} \ + --activate \ + --verbose + colima status + [ "$(docker context show)" = colima ] || die "Colima did not activate its docker context (current: $(docker context show))." + docker buildx version + if [ -z "$rosetta" ]; then + # QEMU user-mode handlers for foreign architectures, registered in the VM's kernel (binfmt_misc). + docker run --privileged --rm docker.io/tonistiigi/binfmt --install amd64 || + warn "Could not register QEMU amd64 emulation in the VM." + fi + echo "::endgroup::" + + smoke_tests docker /var/run/docker.sock + echo "Docker engine ready under $engine_dir." +} + +install_podman() { + local dl=$1 + + echo "::group::Install podman remote client $PODMAN_VERSION" + local podman_asset=podman-remote-release-darwin_arm64.zip + local podman_base="https://github.com/containers/podman/releases/download/$PODMAN_VERSION" + fetch "$podman_base/$podman_asset" "$dl/$podman_asset" + fetch "$podman_base/shasums" "$dl/podman-shasums" + verify "$dl/$podman_asset" "$(sha_for "$dl/podman-shasums" "$podman_asset")" + mkdir -p "$dl/podman" + unzip -q "$dl/$podman_asset" -d "$dl/podman" + # The zip unpacks to `podman-/usr/bin/podman` (plus docs and the mac helper, unused here). + local podman_bin + podman_bin=$(find "$dl/podman" -type f -name podman -path '*/bin/*' | head -n 1) + [ -n "$podman_bin" ] || die "The podman remote client zip contains no bin/podman." + command install -m 0755 "$podman_bin" "$bin_dir/podman" + echo "::endgroup::" + + rm -rf "$dl" + export LIMA_HOME=$podman_lima_home + mkdir -p "$LIMA_HOME" + local sock="$LIMA_HOME/$PODMAN_INSTANCE/sock/podman.sock" + export CONTAINER_HOST="unix://$sock" + export_env "CONTAINER_CLI=podman" "LIMA_HOME=$LIMA_HOME" "CONTAINER_HOST=$CONTAINER_HOST" + + echo "::group::Start the Podman VM" + vm_size + ensure_rosetta || true + # Lima's own template: Fedora with Podman from dnf, rootful, `podman.socket` on + # `/run/podman/podman.sock`, forwarded to `/sock/podman.sock` on the host. + # `--mount-writable` turns the template's default read-only `~` mount writable. `--tty=false`: no + # interactive "proceed with this configuration?" prompt. + limactl start \ + --tty=false \ + --name="$PODMAN_INSTANCE" \ + --vm-type vz \ + --mount-type virtiofs \ + --mount-writable \ + --cpus "$vm_cpus" \ + --memory "$vm_memory" \ + --disk 60 \ + ${rosetta:+--rosetta} \ + template://podman-rootful + limactl list + # The socket forward is set up by Lima's host agent once the guest socket exists; give it a moment. + local i + for i in $(seq 1 60); do + [ -S "$sock" ] && podman info >/dev/null 2>&1 && break + sleep 1 + done + podman info >/dev/null || die "podman cannot reach the VM's API socket through $sock." + if [ -z "$rosetta" ]; then + limactl shell "$PODMAN_INSTANCE" sudo dnf install -y qemu-user-static-x86 || + warn "Could not install QEMU amd64 emulation in the VM." + fi + echo "::endgroup::" + + smoke_tests podman /run/podman/podman.sock + echo "Podman engine ready under $engine_dir." +} + +engine_install() { + local engine=${1:-} + case "$engine" in + docker | podman) ;; + *) die "Usage: $0 install docker|podman" ;; + esac + [ "$(uname -s)" = Darwin ] || die "This script is for macOS; this is $(uname -s)." + [ "$(uname -m)" = arm64 ] || die "This script is for Apple Silicon (arm64); this is $(uname -m)." + local macos_major + macos_major=$(sw_vers -productVersion | cut -d. -f1) + [ "$macos_major" -ge 13 ] || die "Lima's vz driver needs macOS 13 or newer; this is $(sw_vers -productVersion)." + + rm -rf "$engine_dir" + mkdir -p "$bin_dir" "$DOCKER_CONFIG/cli-plugins" "$COLIMA_HOME" "$tmp_dir" + local dl=$engine_dir/downloads + mkdir -p "$dl" + + install_lima "$dl" + "install_$engine" "$dl" +} + +engine_teardown() { + if command -v colima >/dev/null 2>&1 && [ -d "$COLIMA_HOME" ]; then + colima stop --force 2>/dev/null || true + colima delete --force 2>/dev/null || true + fi + if command -v limactl >/dev/null 2>&1 && [ -d "$podman_lima_home/$PODMAN_INSTANCE" ]; then + LIMA_HOME=$podman_lima_home limactl stop --tty=false --force "$PODMAN_INSTANCE" 2>/dev/null || true + LIMA_HOME=$podman_lima_home limactl delete --tty=false --force "$PODMAN_INSTANCE" 2>/dev/null || true + fi + # Lima's host agents and the ssh sessions they keep to the VMs, matched by their own names plus the + # engine dir on their command line - Lima normally ends them; this is for a half-started VM. Not a + # bare `pkill -f "$engine_dir"`: that also matches any shell whose command line mentions the dir. + pkill -f "limactl.*$engine_dir" 2>/dev/null || true + pkill -f "ssh.*$engine_dir" 2>/dev/null || true + rm -rf "$engine_dir" + # Lima keeps the downloaded VM images here, outside LIMA_HOME. Removed for a fully clean machine; a + # runner that stays up between jobs could keep it and skip the ~700 MB download next time. + rm -rf "$HOME/Library/Caches/lima" "$HOME/Library/Caches/colima" + echo "Container engine removed." +} + +case "${1:-}" in + install) engine_install "${2:-}" ;; + teardown) engine_teardown ;; + *) + echo "Usage: $0 install docker|podman | teardown" >&2 + exit 2 + ;; +esac diff --git a/.github/scripts/macos-docker-engine.sh b/.github/scripts/macos-docker-engine.sh deleted file mode 100755 index bb8ad21b..00000000 --- a/.github/scripts/macos-docker-engine.sh +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env bash -# Puts a Docker engine on a clean macOS arm64 host for the e2e suite, and takes it away again - -# for the self-hosted runner in `ci.yml`, which has no Docker, Podman or OrbStack of its own. -# -# Everything is user space: no sudo, no Homebrew, no GUI login session, nothing under /usr/local or -# /Applications. The pieces, all pinned, all downloaded straight from their projects' releases and -# verified against the checksums those releases publish: -# - the static Docker CLI (download.docker.com) - the `docker` the suite drives the engine with -# - the buildx CLI plugin - the runtime's Dockerfile needs BuildKit (`FROM --platform=$BUILDPLATFORM`), -# and Docker CLI >= 23 only builds with BuildKit through this plugin -# - Lima + Colima - Colima runs the Docker daemon inside a Linux VM on Apple's Virtualization.framework -# (`--vm-type vz`, macOS 13+), the engine most Docker-Desktop-less Mac developers use -# -# All of it lives under one directory, `$E2E_ENGINE_DIR` (default `$HOME/.actor-runtime-e2e`): the -# binaries, Colima's state (`COLIMA_HOME`, which also places Lima's `LIMA_HOME` under it) and an isolated -# `DOCKER_CONFIG` for the docker context and the plugin. `teardown` deletes the VM and that directory, -# plus Lima's image download cache in `~/Library/Caches`, leaving the machine as it was found. The -# runner user's own `~/.docker`, `~/.colima` and `~/.lima` are never touched. -# -# The directory is deliberately short and directly under `$HOME`, not under `$RUNNER_TEMP`: Lima puts -# unix sockets in the instance dir (`/colima/ssh.sock.<16 digits>`), and macOS caps a -# socket path at 104 characters - the runner's `_work/_temp/...` path pushed that to 112 and Lima -# refused to start. -# -# Why no `DOCKER_HOST`: the suite mounts `hostEngineSocketPath()` into the runtime container, and with -# no `DOCKER_HOST` that is `/var/run/docker.sock` - which inside Colima's VM, where the container runs, -# IS the daemon's socket. Colima's host-side socket (`$COLIMA_HOME/default/docker.sock`) would not work -# there: a bind mount of a macOS unix socket does not cross into the VM. The CLI reaches the daemon -# through the docker context Colima activates (`--activate`, the default), which needs no env var. -# -# Usage: macos-docker-engine.sh install | teardown -# install downloads, verifies, starts the VM, smoke-tests the socket mount, and exports -# E2E_ENGINE_DIR / COLIMA_HOME / DOCKER_CONFIG and the PATH additions to the job -# (`$GITHUB_ENV` / `$GITHUB_PATH`) so every later step sees the same engine -# teardown stops and deletes the VM and removes everything `install` created; safe to run when -# `install` failed halfway or never ran -set -euo pipefail - -# The two entry points are `engine_install` / `engine_teardown`, not `install` / `teardown`: a bash -# function named `install` shadows /usr/bin/install, so the `install -m 0755` copies below would call -# the function itself - endless recursion, re-downloading on every round, until bash segfaults. The -# copies also say `command install` so that can never come back with a rename. -DOCKER_VERSION=29.8.1 -BUILDX_VERSION=v0.37.1 -LIMA_VERSION=2.2.0 -COLIMA_VERSION=v0.10.3 - -engine_dir=${E2E_ENGINE_DIR:-$HOME/.actor-runtime-e2e} -engine_dir=${engine_dir%/} -bin_dir=$engine_dir/bin -lima_bin_dir=$engine_dir/lima/bin -export COLIMA_HOME=$engine_dir/colima -export DOCKER_CONFIG=$engine_dir/docker-config -export PATH=$bin_dir:$lima_bin_dir:$PATH - -die() { - echo "::error::$*" >&2 - exit 1 -} - -fetch() { - # --retry covers the transient failures a GitHub release download sees; -f makes a 404 (a wrong - # pin) fail here, with the URL, rather than as a corrupt file three steps later. - curl -fsSL --retry 3 --retry-delay 2 -o "$2" "$1" || die "Download failed: $1" -} - -# verify -verify() { - [ -n "$2" ] || die "No published checksum found for $1 - the release's checksum list does not name this asset." - local actual - actual=$(shasum -a 256 "$1" | cut -d' ' -f1) - [ "$actual" = "$2" ] || die "Checksum mismatch for $1: expected $2, got $actual" -} - -# sha_for : the hash a `sha256sum`-style listing gives for one file. -sha_for() { - awk -v name="$2" '{ f = $2; sub(/^\*/, "", f); if (f == name) { print $1; exit } }' "$1" -} - -engine_install() { - [ "$(uname -s)" = Darwin ] || die "This script is for macOS; this is $(uname -s)." - [ "$(uname -m)" = arm64 ] || die "This script is for Apple Silicon (arm64); this is $(uname -m)." - local macos_major - macos_major=$(sw_vers -productVersion | cut -d. -f1) - [ "$macos_major" -ge 13 ] || die "Colima's vz driver needs macOS 13 or newer; this is $(sw_vers -productVersion)." - - rm -rf "$engine_dir" - mkdir -p "$bin_dir" "$DOCKER_CONFIG/cli-plugins" "$COLIMA_HOME" - local dl=$engine_dir/downloads - mkdir -p "$dl" - - echo "::group::Install Docker CLI $DOCKER_VERSION" - # The static bundle is served over TLS from Docker's own host and publishes no separate checksum; - # the daemon it will talk to is the pinned one Colima brings, not this tarball's concern. - fetch "https://download.docker.com/mac/static/stable/aarch64/docker-$DOCKER_VERSION.tgz" "$dl/docker.tgz" - tar -xzf "$dl/docker.tgz" -C "$dl" - command install -m 0755 "$dl/docker/docker" "$bin_dir/docker" - echo "::endgroup::" - - echo "::group::Install buildx $BUILDX_VERSION" - local buildx_asset="buildx-$BUILDX_VERSION.darwin-arm64" - local buildx_base="https://github.com/docker/buildx/releases/download/$BUILDX_VERSION" - fetch "$buildx_base/$buildx_asset" "$dl/$buildx_asset" - # `checksums-signed.txt`, not `checksums.txt`: buildx code-signs its darwin and windows binaries after - # the main list is written, so only the signed list names them. - fetch "$buildx_base/checksums-signed.txt" "$dl/buildx-checksums.txt" - verify "$dl/$buildx_asset" "$(sha_for "$dl/buildx-checksums.txt" "$buildx_asset")" - command install -m 0755 "$dl/$buildx_asset" "$DOCKER_CONFIG/cli-plugins/docker-buildx" - echo "::endgroup::" - - echo "::group::Install Lima $LIMA_VERSION" - local lima_asset="lima-$LIMA_VERSION-Darwin-arm64.tar.gz" - local lima_base="https://github.com/lima-vm/lima/releases/download/v$LIMA_VERSION" - fetch "$lima_base/$lima_asset" "$dl/$lima_asset" - fetch "$lima_base/SHA256SUMS" "$dl/lima-SHA256SUMS" - verify "$dl/$lima_asset" "$(sha_for "$dl/lima-SHA256SUMS" "$lima_asset")" - mkdir -p "$engine_dir/lima" - # The tarball is `bin/limactl`, `share/lima/...` at its root; `limactl` is ad-hoc signed with the - # Virtualization entitlement by Lima's release build, which a curl download keeps (no quarantine). - tar -xzf "$dl/$lima_asset" -C "$engine_dir/lima" - echo "::endgroup::" - - echo "::group::Install Colima $COLIMA_VERSION" - local colima_asset=colima-Darwin-arm64 - local colima_base="https://github.com/abiosoft/colima/releases/download/$COLIMA_VERSION" - fetch "$colima_base/$colima_asset" "$dl/$colima_asset" - fetch "$colima_base/$colima_asset.sha256sum" "$dl/$colima_asset.sha256sum" - verify "$dl/$colima_asset" "$(cut -d' ' -f1 "$dl/$colima_asset.sha256sum")" - command install -m 0755 "$dl/$colima_asset" "$bin_dir/colima" - echo "::endgroup::" - - rm -rf "$dl" - - # Exported before the VM start, not after: the workflow's later steps (the log dump on failure, - # `teardown`) need `docker` and `colima` on their PATH even when the start below fails. - if [ -n "${GITHUB_ENV:-}" ]; then - { - echo "E2E_ENGINE_DIR=$engine_dir" - echo "COLIMA_HOME=$COLIMA_HOME" - echo "DOCKER_CONFIG=$DOCKER_CONFIG" - } >> "$GITHUB_ENV" - fi - if [ -n "${GITHUB_PATH:-}" ]; then - printf '%s\n' "$bin_dir" "$lima_bin_dir" >> "$GITHUB_PATH" - fi - - echo "::group::Start the Docker VM" - # Half the machine, with a floor: the runtime image build (tsc, pip) and two Actor builds run in here, - # and Colima's own defaults (2 CPUs, 2 GiB) are tight for that. - local ncpu memsize cpus memory - ncpu=$(sysctl -n hw.ncpu) - memsize=$(sysctl -n hw.memsize) - cpus=$((ncpu / 2)) - [ "$cpus" -ge 2 ] || cpus=2 - memory=$((memsize / 1024 / 1024 / 1024 / 2)) - [ "$memory" -ge 4 ] || memory=4 - echo "VM: $cpus CPUs, ${memory} GiB memory (host: $ncpu CPUs, $((memsize / 1024 / 1024 / 1024)) GiB)" - # `--disk` is a sparse maximum, not an allocation. No `--vz-rosetta`: nothing in the dev-loop file - # needs amd64 emulation; it is the switch to flip when the browser-view files join this leg. No - # extra `--mount`: the dev-loop file bind-mounts nothing from the host (the runtime container's - # `/data` is a named volume) - the dev-folder files would need `$TMPDIR` mounted writable. - colima start \ - --runtime docker \ - --vm-type vz \ - --mount-type virtiofs \ - --arch aarch64 \ - --cpu "$cpus" \ - --memory "$memory" \ - --disk 60 \ - --activate \ - --verbose - echo "::endgroup::" - - echo "::group::Check the engine" - colima status - [ "$(docker context show)" = colima ] || die "Colima did not activate its docker context (current: $(docker context show))." - docker version - docker buildx version - docker info --format 'Arch={{.Architecture}} OS={{.OperatingSystem}} Server={{.ServerVersion}}' - # The exact mount the suite gives the runtime container - proven before the suite spends minutes - # building the image, so an engine that cannot do this fails here, with this message. - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock docker.io/library/busybox \ - test -S /var/run/docker.sock || - die "/var/run/docker.sock is not a socket inside a container on this engine; the runtime container needs it to be." - echo "::endgroup::" - echo "Docker engine ready under $engine_dir." -} - -engine_teardown() { - if command -v colima >/dev/null 2>&1 && [ -d "$COLIMA_HOME" ]; then - colima stop --force 2>/dev/null || true - colima delete --force 2>/dev/null || true - fi - # Lima's host agent and the ssh sessions it keeps to the VM, matched by their own names plus Colima's - # state dir on their command line - Colima normally ends them; this is for a half-started VM. Not a - # bare `pkill -f "$engine_dir"`: that also matches any shell whose command line mentions the dir. - pkill -f "limactl.*$COLIMA_HOME" 2>/dev/null || true - pkill -f "ssh.*$COLIMA_HOME" 2>/dev/null || true - rm -rf "$engine_dir" - # Lima keeps the downloaded VM images here, outside LIMA_HOME. Removed for a fully clean machine; a - # runner that stays up between jobs could keep it and skip the ~700 MB download next time. - rm -rf "$HOME/Library/Caches/lima" "$HOME/Library/Caches/colima" - echo "Docker engine removed." -} - -case "${1:-}" in - install) engine_install ;; - teardown) engine_teardown ;; - *) - echo "Usage: $0 install | teardown" >&2 - exit 2 - ;; -esac diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b555204..1bdd4984 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,8 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile # Only this file. The browser-based cases need the amd64 emulation `startBuild`'s fallback - # builds under, and nothing here installs binfmt/QEMU - an Apple Silicon Mac runs them. + # builds under, and nothing here installs binfmt/QEMU - `e2e-macos-arm64` runs them, under + # Rosetta. - run: pnpm exec vitest run test/e2e/actor-dev-loop.test.ts - name: Dump runtime container logs on failure if: failure() @@ -114,31 +115,43 @@ jobs: done e2e-macos-arm64: - name: e2e actor-dev-loop (apify-cli + docker on macOS arm64, self-hosted) - # The one leg on the platform most developers run the runtime on: an Apple Silicon Mac, with the - # engine in a Linux VM and the socket the runtime container gets mounted (`/var/run/docker.sock`) - # being the VM's, not a kernel socket of the host. The runner is a clean Mac - no Docker, Podman - # or OrbStack - so the job brings its own engine (Colima + the Docker CLI, user space, no sudo; - # `.github/scripts/macos-docker-engine.sh`) and removes it again when done. It is a persistent - # self-hosted machine, so unlike every hosted leg above it is also cleaned before and after. + name: e2e all files (apify-cli + ${{ matrix.engine }} on macOS arm64, self-hosted) + # The leg on the platform most developers run the runtime on: an Apple Silicon Mac, with the + # engine in a Linux VM, the host's `$HOME` shared into it, and the socket the runtime container + # gets mounted being the VM's, not a kernel socket of the host. The runner is a clean Mac - no + # Docker, Podman or OrbStack - so each job brings its own engine (user space, no sudo; + # `.github/scripts/macos-container-engine.sh`) and removes it again when done: Docker via Colima, + # Podman via Lima's own template. It is a persistent self-hosted machine, so unlike every hosted + # leg above it is also cleaned before and after. + # + # One job per engine running every e2e file in turn (`test:e2e` serializes them: fixed host + # ports), rather than one job per file as above: the engine install is per job, and there is + # one runner, so jobs queue rather than parallelize anyway. The browser-view files pull ~2 GB + # base images and run Chrome under amd64 emulation, hence the wide budget. # # Self-hosted, so never for a fork's pull request: `pull_request` runs the head branch's code, # and the runner is a long-lived Apify machine. Pushes to master and same-repo PRs only. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: [self-hosted, macOS, ARM64] - timeout-minutes: 45 + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + engine: + - docker + - podman steps: - uses: actions/checkout@v4 - # Downloads pinned Docker CLI, buildx, Lima and Colima into one directory under the runner - # user's home (removed again in the last step), starts the VM and smoke-tests the socket mount - # the suite relies on. Exports the engine's env to the job; it deliberately does not set - # `DOCKER_HOST` (see the script for why). - - name: Install a Docker engine (Colima, user space) - run: .github/scripts/macos-docker-engine.sh install + # Downloads the pinned pieces into one directory under the runner user's home (removed again + # in the last step), starts the VM and smoke-tests the socket and home mounts the suite relies + # on. Exports the engine's env (`CONTAINER_CLI`, `TMPDIR`, ...) to the job; it deliberately + # does not set `DOCKER_HOST` (see the script for why). + - name: Install a ${{ matrix.engine }} engine (Lima VM, user space) + run: .github/scripts/macos-container-engine.sh install ${{ matrix.engine }} # A previous job on this machine may have been cancelled or timed out mid-suite. The engine is # new, so there is nothing of ours left in it; this catches the suite's temp dirs and, above # all, fails with the holder named if 3333/3000 is taken by something that is not ours, rather - # than letting `docker run` fail deep in `beforeAll`. + # than letting the container start fail deep in `beforeAll`. - name: Clean up leftovers of earlier runs run: .github/scripts/e2e-host-cleanup.sh --require-free-ports - uses: pnpm/action-setup@v4 @@ -147,24 +160,22 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - # Only this file for now - the smallest end-to-end exercise of the whole chain (build the - # runtime image, run it, push/build/call two Actors through apify-cli) on this executor. The - # browser-view and all-modes files are the candidates to add next; their fallback builds need - # amd64 emulation, which is Colima's `--vz-rosetta` switch in the install script. - - run: pnpm exec vitest run test/e2e/actor-dev-loop.test.ts + # Every e2e file, one after another, on this engine. + - run: pnpm run test:e2e - name: Dump runtime container logs on failure if: failure() run: | - for c in $(docker ps -aq --filter name=actor-runtime-e2e); do - docker logs --tail 300 "$c" || true + cli=${CONTAINER_CLI:-docker} + for c in $($cli ps -aq --filter name=actor-runtime-e2e); do + $cli logs --tail 300 "$c" || true done # Always, so the next job starts clean whatever happened here. The engine and everything in # it goes in the next step anyway; this one is for the suite's temp dirs on the host. - name: Clean up if: always() run: .github/scripts/e2e-host-cleanup.sh - # Stops and deletes the VM and removes the binaries, Colima's state, the isolated docker config - # and Lima's image cache - the machine is left as the job found it. - - name: Remove the Docker engine + # Stops and deletes the VM and removes the binaries, the engines' state, the isolated docker + # config and Lima's image cache - the machine is left as the job found it. + - name: Remove the container engine if: always() - run: .github/scripts/macos-docker-engine.sh teardown + run: .github/scripts/macos-container-engine.sh teardown diff --git a/requirements/test.md b/requirements/test.md index 17519d3b..a29d7692 100644 --- a/requirements/test.md +++ b/requirements/test.md @@ -6,7 +6,7 @@ - CI (GitHub Actions) runs on every pull request and on pushes to the main branches: build, lint, format check, and all test layers, with the mandatory CLI-only e2e suite below executing against a real Docker daemon. A missing daemon fails the CI job - the e2e suite never silently skips. - CI runs each e2e file as its own job, in parallel; locally the files run one after another (each starts a runtime container on the fixed ports). -- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the Actor dev-loop e2e file on a self-hosted macOS arm64 runner - the platform most developers use the runtime from. That runner is a clean, persistent Mac with no container engine of its own, so the job installs one for itself in user space (Colima's Docker VM plus the Docker CLI, pinned and checksum-verified, no sudo), runs the test, then removes everything the suite left behind (its temp dirs, and the engine with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. +- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the whole e2e suite on a self-hosted macOS arm64 runner - the platform most developers use the runtime from - once against Docker and once against Podman, each engine in a Linux VM with the host's home directory shared into it, the way Docker Desktop and Podman Desktop work on a Mac. That runner is a clean, persistent Mac with no container engine of its own, so each job installs one for itself in user space (Lima's VM plus Colima and the Docker CLI, or Lima's Podman template plus the podman remote client; pinned and checksum-verified, no sudo), runs the suite, then removes everything it left behind (its temp dirs, and the engine with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. # Mandatory end-to-end tests From ccbf98b426a665d15a45dba9ff53303ddef2128c Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 13:47:21 +0000 Subject: [PATCH 07/21] test(e2e): Mount the engine socket with label=disable on SELinux Podman hosts The macOS Podman leg runs Podman on Fedora (Lima's template, like Podman Desktop's machine) with SELinux enforcing, and there the runtime container cannot see the engine's socket bind-mounted into it - the documented `podman run` from README's "Running with Podman" fails the same way on any such host. `startRuntimeContainer` now adds `--security-opt label=disable` when Podman reports SELinux enabled (Docker hosts and the Ubuntu-based Podman legs stay unchanged), the install script's smoke test does the same and prints the socket's label and enforcing mode for diagnosis, and the README documents the flag for users. Also uses Lima v2's `template:` locator spelling. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-container-engine.sh | 16 +++++++++++++--- README.md | 5 +++++ test/e2e/helpers/docker.ts | 21 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/.github/scripts/macos-container-engine.sh b/.github/scripts/macos-container-engine.sh index b3644144..99f6212e 100755 --- a/.github/scripts/macos-container-engine.sh +++ b/.github/scripts/macos-container-engine.sh @@ -152,14 +152,22 @@ smoke_tests() { "$cli" version "$cli" info --format 'Arch={{.Host.Arch}} OS={{.Host.Distribution.Distribution}}' 2>/dev/null || "$cli" info --format 'Arch={{.Architecture}} OS={{.OperatingSystem}} Server={{.ServerVersion}}' + # What the suite's `startRuntimeContainer` adds on a Podman host with SELinux enforcing (Fedora, as + # Lima's Podman template and Podman Desktop's machine both are), and what README's "Running with + # Podman" tells users to add: without it SELinux keeps a container from the socket mounted into it. + local -a secopt=() + if [ "$cli" = podman ] && [ "$("$cli" info --format '{{.Host.Security.SELinuxEnabled}}' 2>/dev/null)" = true ]; then + echo "SELinux is enabled on the engine host; containers that mount the socket get --security-opt label=disable." + secopt=(--security-opt label=disable) + fi # The mount the runtime container gets: the daemon's own socket, reachable from inside a container. - "$cli" run --rm -v "$sock:/var/run/docker.sock" docker.io/library/busybox test -S /var/run/docker.sock || + "$cli" run --rm "${secopt[@]}" -v "$sock:/var/run/docker.sock" docker.io/library/busybox test -S /var/run/docker.sock || die "$sock is not a socket inside a container on this engine; the runtime container needs it to be." # A host directory under $HOME, bind-mounted by its host path - what the dev-folder files do. mkdir -p "$tmp_dir" local marker="$tmp_dir/.mount-probe" echo ok > "$marker" - "$cli" run --rm -v "$tmp_dir:/probe" docker.io/library/busybox cat /probe/.mount-probe >/dev/null || + "$cli" run --rm "${secopt[@]}" -v "$tmp_dir:/probe" docker.io/library/busybox cat /probe/.mount-probe >/dev/null || die "$tmp_dir is not visible inside the VM by its host path; the dev-folder e2e files need \$HOME shared into the VM." rm -f "$marker" # amd64 emulation, for the browser-view files. A warning, not a failure: only those files need it, @@ -312,7 +320,7 @@ install_podman() { --memory "$vm_memory" \ --disk 60 \ ${rosetta:+--rosetta} \ - template://podman-rootful + template:podman-rootful limactl list # The socket forward is set up by Lima's host agent once the guest socket exists; give it a moment. local i @@ -321,6 +329,8 @@ install_podman() { sleep 1 done podman info >/dev/null || die "podman cannot reach the VM's API socket through $sock." + # For reading a failed socket smoke test below: the socket's SELinux label and the enforcing mode. + limactl shell "$PODMAN_INSTANCE" -- sh -c 'getenforce; ls -laZ /run/podman/' || true if [ -z "$rosetta" ]; then limactl shell "$PODMAN_INSTANCE" sudo dnf install -y qemu-user-static-x86 || warn "Could not install QEMU amd64 emulation in the VM." diff --git a/README.md b/README.md index 973e1646..f11d3862 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,11 @@ sudo podman run --rm -p 3333:3333 -p 3000:3000 \ actor-runtime ``` +On a host where SELinux is enforcing - Fedora, and the Fedora-based VM behind Podman Desktop / +`podman machine` on macOS and Windows - add `--security-opt label=disable` to that `podman run`: +SELinux otherwise keeps the runtime container from the engine's socket mounted into it, and the +runtime reports the engine as unavailable. + Rootless Podman serves the socket at `$XDG_RUNTIME_DIR/podman/podman.sock` instead (`systemctl --user enable --now podman.socket`); mount that path and drop the `sudo`. Rootless Docker works the same way with its `$XDG_RUNTIME_DIR/docker.sock`. The socket can also be mounted at any other diff --git a/test/e2e/helpers/docker.ts b/test/e2e/helpers/docker.ts index e3b8193e..d1d110d2 100644 --- a/test/e2e/helpers/docker.ts +++ b/test/e2e/helpers/docker.ts @@ -87,6 +87,26 @@ export function pullImage(image: string): void { execFileSync(CONTAINER_CLI, ['pull', '--platform', COMPATIBILITY_BUILD_PLATFORM, image], { stdio: 'inherit' }); } +/** + * `--security-opt label=disable` for the runtime container on a Podman host with SELinux enforcing + * (Fedora; the Fedora-based VM of Podman Desktop / `podman machine` on macOS and Windows): without it, + * SELinux keeps the container from the engine's socket bind-mounted into it - README's "Running with + * Podman" documents the same flag for users. Docker hosts and Podman hosts without SELinux get nothing + * extra, so the suite there runs the documented command verbatim, as before. + */ +function engineSecurityOptions(): string[] { + if (CONTAINER_CLI !== 'podman') return []; + try { + const enabled = execFileSync(CONTAINER_CLI, ['info', '--format', '{{.Host.Security.SELinuxEnabled}}'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return enabled === 'true' ? ['--security-opt', 'label=disable'] : []; + } catch { + return []; + } +} + export function startRuntimeContainer(tag: string, containerName: string): void { // A previous run that died before its `afterAll` (killed vitest, a CI job cancelled or timed out on a // persistent self-hosted runner) leaves a container of this name, and with it the fixed host ports @@ -116,6 +136,7 @@ export function startRuntimeContainer(tag: string, containerName: string): void `${hostEngineSocketPath()}:${RUNTIME_SOCKET_PATH}`, '-v', `${containerName}-data:/data`, + ...engineSecurityOptions(), tag, ], { stdio: 'inherit' }, From 63705e6af01f24786d1b9cf71e7b958920a00047 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 14:01:54 +0000 Subject: [PATCH 08/21] fix(ci): Keep the macOS engine smoke test bash-3.2-safe macOS's bash 3.2 treats an empty array expansion (`"${secopt[@]}"`) as an unbound variable under `set -u`, which failed the Docker leg's smoke test right after the VM came up. The security options are now a plain string, empty on Docker, and the engine-info line picks its format per CLI instead of trying Podman's first. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-container-engine.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/scripts/macos-container-engine.sh b/.github/scripts/macos-container-engine.sh index 99f6212e..b61ae182 100755 --- a/.github/scripts/macos-container-engine.sh +++ b/.github/scripts/macos-container-engine.sh @@ -150,24 +150,31 @@ smoke_tests() { local cli=$1 sock=$2 echo "::group::Check the engine" "$cli" version - "$cli" info --format 'Arch={{.Host.Arch}} OS={{.Host.Distribution.Distribution}}' 2>/dev/null || + if [ "$cli" = podman ]; then + "$cli" info --format 'Arch={{.Host.Arch}} OS={{.Host.Distribution.Distribution}} {{.Host.Distribution.Version}}' + else "$cli" info --format 'Arch={{.Architecture}} OS={{.OperatingSystem}} Server={{.ServerVersion}}' + fi # What the suite's `startRuntimeContainer` adds on a Podman host with SELinux enforcing (Fedora, as # Lima's Podman template and Podman Desktop's machine both are), and what README's "Running with # Podman" tells users to add: without it SELinux keeps a container from the socket mounted into it. - local -a secopt=() + # A plain string, expanded unquoted below: macOS's bash 3.2 treats an empty array expansion as an + # unbound variable under `set -u`. + local secopt= if [ "$cli" = podman ] && [ "$("$cli" info --format '{{.Host.Security.SELinuxEnabled}}' 2>/dev/null)" = true ]; then echo "SELinux is enabled on the engine host; containers that mount the socket get --security-opt label=disable." - secopt=(--security-opt label=disable) + secopt="--security-opt label=disable" fi # The mount the runtime container gets: the daemon's own socket, reachable from inside a container. - "$cli" run --rm "${secopt[@]}" -v "$sock:/var/run/docker.sock" docker.io/library/busybox test -S /var/run/docker.sock || + # shellcheck disable=SC2086 + "$cli" run --rm $secopt -v "$sock:/var/run/docker.sock" docker.io/library/busybox test -S /var/run/docker.sock || die "$sock is not a socket inside a container on this engine; the runtime container needs it to be." # A host directory under $HOME, bind-mounted by its host path - what the dev-folder files do. mkdir -p "$tmp_dir" local marker="$tmp_dir/.mount-probe" echo ok > "$marker" - "$cli" run --rm "${secopt[@]}" -v "$tmp_dir:/probe" docker.io/library/busybox cat /probe/.mount-probe >/dev/null || + # shellcheck disable=SC2086 + "$cli" run --rm $secopt -v "$tmp_dir:/probe" docker.io/library/busybox cat /probe/.mount-probe >/dev/null || die "$tmp_dir is not visible inside the VM by its host path; the dev-folder e2e files need \$HOME shared into the VM." rm -f "$marker" # amd64 emulation, for the browser-view files. A warning, not a failure: only those files need it, From fb013aeb24041f6088540d96f038cc052ccf9874 Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 14:21:01 +0000 Subject: [PATCH 09/21] test(e2e): Count dangling volumes through the suite's engine CLI The dev-folder file's "anonymous node_modules volumes do not accumulate" case ran a hardcoded `docker volume ls`. On the Podman legs that counted the runner's unrelated Docker daemon's volumes (a vacuous pass), and on the macOS Podman leg, which has no `docker` binary, it failed with ENOENT. The count now goes through `helpers/docker.ts`'s `CONTAINER_CLI`, like every other engine call in the suite. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- test/e2e/dev-folder-bind-mount.test.ts | 6 ++---- test/e2e/helpers/docker.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/test/e2e/dev-folder-bind-mount.test.ts b/test/e2e/dev-folder-bind-mount.test.ts index 80f79a64..b53c4ed0 100644 --- a/test/e2e/dev-folder-bind-mount.test.ts +++ b/test/e2e/dev-folder-bind-mount.test.ts @@ -22,6 +22,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildRuntimeImage, + danglingVolumeIds, isDockerAvailable, pullBaseImages, startRuntimeContainer, @@ -432,10 +433,7 @@ describe('local dev-folder bind mount: edit-compile-call loop with no rebuild (r // the only thing exercising anonymous volumes against this daemon in a CI run, so a stable count // across repeated runs is sufficient evidence the driver's `{ v: true }` cleanup (not some // unrelated daemon-wide accumulation) is what's being measured. - const countDanglingVolumes = (): number => - execFileSync('docker', ['volume', 'ls', '-q', '-f', 'dangling=true'], { encoding: 'utf8' }) - .split('\n') - .filter((line) => line.trim().length > 0).length; + const countDanglingVolumes = (): number => danglingVolumeIds().length; const before = countDanglingVolumes(); for (let i = 0; i < 3; i++) { diff --git a/test/e2e/helpers/docker.ts b/test/e2e/helpers/docker.ts index d1d110d2..394f2ddb 100644 --- a/test/e2e/helpers/docker.ts +++ b/test/e2e/helpers/docker.ts @@ -178,6 +178,15 @@ function removeContainerAndDataVolume(containerName: string): void { } } +/** Ids of the engine's dangling (unattached) volumes - through `CONTAINER_CLI`, like every other engine + * call here. A hardcoded `docker` would count the wrong daemon's volumes on a Podman host (or, with no + * `docker` binary at all, as on the macOS Podman leg, fail with ENOENT). */ +export function danglingVolumeIds(): string[] { + return execFileSync(CONTAINER_CLI, ['volume', 'ls', '-q', '-f', 'dangling=true'], { encoding: 'utf8' }) + .split('\n') + .filter((line) => line.trim() !== ''); +} + export async function waitForHttpOk(url: string, timeoutMs = 60_000): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown; From e3aa6a5b5d47b8bc2e2ea5d41ee0649784f4371b Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 14:40:24 +0000 Subject: [PATCH 10/21] ci: Log the macOS engines into Docker Hub to stay under the pull rate limit Each macOS job starts from an empty engine and pulls every base image again, and Docker Hub's anonymous pull limit is per IP - a day of runs on the one Mac hit "toomanyrequests" in five of seven e2e files' setup. The install script now logs both CLIs into Docker Hub when the workflow passes the Apify service account's credentials (the repository secrets `release.yml` already publishes with), so pulls count against the account. The credentials stay in the isolated `DOCKER_CONFIG` / `REGISTRY_AUTH_FILE` under the engine dir, which teardown removes; without credentials the script warns and pulls anonymously as before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-container-engine.sh | 25 +++++++++++++++++++++++ .github/workflows/ci.yml | 8 ++++++++ requirements/test.md | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/scripts/macos-container-engine.sh b/.github/scripts/macos-container-engine.sh index b61ae182..5c9f9dd8 100755 --- a/.github/scripts/macos-container-engine.sh +++ b/.github/scripts/macos-container-engine.sh @@ -39,6 +39,13 @@ # emulation registered in the VM (`tonistiigi/binfmt` for Docker, `qemu-user-static` for Podman), # which is slow but works. Installing Rosetta is attempted once; it may need admin rights. # +# Docker Hub: each job starts from an empty engine and pulls every base image again, and Docker Hub's +# anonymous pull limit is per IP - a day of runs on one Mac exhausts it ("toomanyrequests"). With +# `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` in the environment (the workflow passes the same repository +# secrets `release.yml` publishes with), both CLIs log in so pulls count against the account instead; +# the credentials land in the isolated `DOCKER_CONFIG` / `REGISTRY_AUTH_FILE` under the engine dir, +# never in the runner user's own config, and go with the teardown. Missing credentials only warn. +# # Why no `DOCKER_HOST`: the suite mounts `hostEngineSocketPath()` into the runtime container, and with # no `DOCKER_HOST` that is `/var/run/docker.sock` (docker) or `/run/podman/podman.sock` (podman) - # which inside the VM, where the container runs, IS the daemon's socket. The host-side forwarded @@ -72,6 +79,7 @@ lima_bin_dir=$engine_dir/lima/bin tmp_dir=$engine_dir/tmp export COLIMA_HOME=$engine_dir/colima export DOCKER_CONFIG=$engine_dir/docker-config +export REGISTRY_AUTH_FILE=$engine_dir/podman-auth.json # Lima home for the standalone (podman) instance. Colima ignores it and uses `$COLIMA_HOME/_lima`. podman_lima_home=$engine_dir/limahome PODMAN_INSTANCE=podman @@ -143,6 +151,20 @@ ensure_rosetta() { rosetta= } +# registry_login : authenticated Docker Hub pulls, when the workflow provides credentials. +registry_login() { + local cli=$1 + if [ -z "${DOCKERHUB_USERNAME:-}" ] || [ -z "${DOCKERHUB_TOKEN:-}" ]; then + warn "No Docker Hub credentials (DOCKERHUB_USERNAME / DOCKERHUB_TOKEN); pulls are anonymous and count against this Mac's IP-based rate limit." + return 0 + fi + # docker: into $DOCKER_CONFIG/config.json. podman: into $REGISTRY_AUTH_FILE (exported to the job by + # `export_env`), which the remote client also hands to the VM's daemon for its pulls. + printf '%s' "$DOCKERHUB_TOKEN" | "$cli" login docker.io -u "$DOCKERHUB_USERNAME" --password-stdin || + die "Docker Hub login failed for '$DOCKERHUB_USERNAME'." + echo "Logged in to Docker Hub as $DOCKERHUB_USERNAME." +} + # smoke_tests : the exact things the suite relies on, proven # before it spends minutes building images, so an engine that cannot do them fails here, with a # message that says which. @@ -196,6 +218,7 @@ export_env() { echo "E2E_ENGINE_DIR=$engine_dir" echo "COLIMA_HOME=$COLIMA_HOME" echo "DOCKER_CONFIG=$DOCKER_CONFIG" + echo "REGISTRY_AUTH_FILE=$REGISTRY_AUTH_FILE" echo "TMPDIR=$tmp_dir" printf '%s\n' "$@" } >> "$GITHUB_ENV" @@ -274,6 +297,7 @@ install_docker() { colima status [ "$(docker context show)" = colima ] || die "Colima did not activate its docker context (current: $(docker context show))." docker buildx version + registry_login docker if [ -z "$rosetta" ]; then # QEMU user-mode handlers for foreign architectures, registered in the VM's kernel (binfmt_misc). docker run --privileged --rm docker.io/tonistiigi/binfmt --install amd64 || @@ -336,6 +360,7 @@ install_podman() { sleep 1 done podman info >/dev/null || die "podman cannot reach the VM's API socket through $sock." + registry_login podman # For reading a failed socket smoke test below: the socket's SELinux label and the enforcing mode. limactl shell "$PODMAN_INSTANCE" -- sh -c 'getenforce; ls -laZ /run/podman/' || true if [ -z "$rosetta" ]; then diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bdd4984..1d3d6ee1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,7 +146,15 @@ jobs: # in the last step), starts the VM and smoke-tests the socket and home mounts the suite relies # on. Exports the engine's env (`CONTAINER_CLI`, `TMPDIR`, ...) to the job; it deliberately # does not set `DOCKER_HOST` (see the script for why). + # The Docker Hub credentials are the Apify service account's, the same repository secrets + # `release.yml` publishes with: every job here starts from an empty engine and pulls every base + # image again, and Docker Hub's anonymous limit is per IP, which one Mac exhausts in a day of + # runs. Logged in, pulls count against the account. Absent (a fork has no secrets, and the job + # does not run for forks anyway), the script warns and pulls anonymously. - name: Install a ${{ matrix.engine }} engine (Lima VM, user space) + env: + DOCKERHUB_USERNAME: ${{ secrets.APIFY_SERVICE_ACCOUNT_DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_DOCKERHUB_TOKEN }} run: .github/scripts/macos-container-engine.sh install ${{ matrix.engine }} # A previous job on this machine may have been cancelled or timed out mid-suite. The engine is # new, so there is nothing of ours left in it; this catches the suite's temp dirs and, above diff --git a/requirements/test.md b/requirements/test.md index a29d7692..af36dbac 100644 --- a/requirements/test.md +++ b/requirements/test.md @@ -6,7 +6,7 @@ - CI (GitHub Actions) runs on every pull request and on pushes to the main branches: build, lint, format check, and all test layers, with the mandatory CLI-only e2e suite below executing against a real Docker daemon. A missing daemon fails the CI job - the e2e suite never silently skips. - CI runs each e2e file as its own job, in parallel; locally the files run one after another (each starts a runtime container on the fixed ports). -- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the whole e2e suite on a self-hosted macOS arm64 runner - the platform most developers use the runtime from - once against Docker and once against Podman, each engine in a Linux VM with the host's home directory shared into it, the way Docker Desktop and Podman Desktop work on a Mac. That runner is a clean, persistent Mac with no container engine of its own, so each job installs one for itself in user space (Lima's VM plus Colima and the Docker CLI, or Lima's Podman template plus the podman remote client; pinned and checksum-verified, no sudo), runs the suite, then removes everything it left behind (its temp dirs, and the engine with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. +- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the whole e2e suite on a self-hosted macOS arm64 runner - the platform most developers use the runtime from - once against Docker and once against Podman, each engine in a Linux VM with the host's home directory shared into it, the way Docker Desktop and Podman Desktop work on a Mac. That runner is a clean, persistent Mac with no container engine of its own, so each job installs one for itself in user space (Lima's VM plus Colima and the Docker CLI, or Lima's Podman template plus the podman remote client; pinned and checksum-verified, no sudo), logs it into Docker Hub with the repository's service-account secrets so the per-job image pulls count against the account rather than the machine's anonymous per-IP limit, runs the suite, then removes everything it left behind (its temp dirs, and the engine with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. # Mandatory end-to-end tests From 040e0d22a1e8f16948b4d29d6137985c6f3eaf3e Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Thu, 17 Sep 2026 15:15:06 +0000 Subject: [PATCH 11/21] ci: Tear down a leftover engine before installing a fresh one on the Mac A job that dies without reaching its teardown step (the runner losing contact with GitHub mid-suite leaves every later step unrun) leaves its Lima VM running and its host agent holding the forwarded ports. The install now runs the teardown first when the engine directory exists, stopping and deleting whatever VM is there with the binaries it left behind, instead of removing the directory from under a live VM. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-container-engine.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/scripts/macos-container-engine.sh b/.github/scripts/macos-container-engine.sh index 5c9f9dd8..8d6db8fd 100755 --- a/.github/scripts/macos-container-engine.sh +++ b/.github/scripts/macos-container-engine.sh @@ -385,7 +385,14 @@ engine_install() { macos_major=$(sw_vers -productVersion | cut -d. -f1) [ "$macos_major" -ge 13 ] || die "Lima's vz driver needs macOS 13 or newer; this is $(sw_vers -productVersion)." - rm -rf "$engine_dir" + # A previous job that died without its teardown step (the runner losing contact with GitHub + # mid-suite leaves the job's later steps unrun) leaves its VM running and its host agent holding the + # forwarded ports. Stop and delete all of that first, with the binaries it left behind, rather than + # `rm -rf` the directory from under a live VM. + if [ -d "$engine_dir" ]; then + echo "Found a leftover engine under $engine_dir; removing it first." + engine_teardown + fi mkdir -p "$bin_dir" "$DOCKER_CONFIG/cli-plugins" "$COLIMA_HOME" "$tmp_dir" local dl=$engine_dir/downloads mkdir -p "$dl" From e03a55a58fcd16e9a04b0c33b44190560573d5aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:21:08 +0000 Subject: [PATCH 12/21] test(e2e): mirror the CLI's SELinux security option in the runtime start command apify-cli's `buildRuntimeRunArgs` now adds `--security-opt label=disable` itself on Podman hosts with SELinux enforcing (apify/apify-cli#1495), placed after the network mode. Move the suite's own `extraRunArgs` escape hatch into the mirrored `runtimeRunArgs` at that same position, so the pinned command is once again the CLI's verbatim, and pin the new shape in the unit test. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- README.md | 6 +++--- test/e2e/helpers/docker.ts | 25 +++++++--------------- test/unit/e2e-runtime-command.test.ts | 30 +++++++++++++-------------- 3 files changed, 24 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 44c16191..b665a17a 100644 --- a/README.md +++ b/README.md @@ -217,9 +217,9 @@ podman run --rm -p 3333:3333 -p 3000:3000 \ - To use other ports, set `-e ACTOR_RUNTIME_API_PORT=4333 -e ACTOR_RUNTIME_CONSOLE_PORT=4000` and publish the same numbers on the host (`-p 4333:4333 -p 4000:4000`). - On a host where SELinux is enforcing - Fedora, and the Fedora-based VM behind Podman Desktop / - `podman machine` on macOS and Windows - add `--security-opt label=disable` to the `podman run` above: - SELinux otherwise keeps the runtime container from the engine's socket mounted into it, and the - runtime reports the engine as unavailable. + `podman machine` on macOS and Windows - add `--security-opt label=disable` to the `podman run` above + (`apify runtime start` does): SELinux otherwise keeps the runtime container from the engine's socket + mounted into it, and the runtime reports the engine as unavailable. - Podman 3.4 (Ubuntu 22.04's stock package) and newer are supported. Keep the API port published on all interfaces (`-p 3333:3333` by default): under Podman 3.x and rootless Podman, Actors reach the API through it. - Under rootless Podman 3.x, add `--network slirp4netns:allow_host_loopback=true` to the `podman run` diff --git a/test/e2e/helpers/docker.ts b/test/e2e/helpers/docker.ts index bb663046..9c0c6fc0 100644 --- a/test/e2e/helpers/docker.ts +++ b/test/e2e/helpers/docker.ts @@ -75,20 +75,21 @@ export interface RuntimeRunArgsOptions { hostSocketPath: string; /** Mounted as the runtime's `/data`: a host directory, as the CLI mounts, or a named volume. */ dataMount: string; - /** Arguments the CLI's command does not have, placed right before the image - the suite's own - * additions for the host it runs on (`engineSecurityOptions`). Empty by default, so the pinned - * CLI-verbatim command is unchanged. */ - extraRunArgs?: string[]; } /** * The command `apify runtime start --detach` runs, as apify-cli's `buildRuntimeRunArgs` builds it (the * container name and the data mount aside), so the suite starts the runtime exactly the way the CLI * does on every engine - including the host-loopback network mode rootless Podman 3.x needs - * (`actor-driver.md`'s "Web server (live view)"). `test/unit/e2e-runtime-command.test.ts` pins it. + * (`actor-driver.md`'s "Web server (live view)") and the SELinux label the CLI's + * `runtimeSecurityOptions` adds on Podman hosts with SELinux enforcing (Fedora; the Fedora-based VM of + * Podman Desktop / `podman machine` on macOS and Windows), without which SELinux keeps the container + * from the engine's socket bind-mounted into it - README's "Running with Podman" documents the same + * flag for users. `test/unit/e2e-runtime-command.test.ts` pins it. */ export function runtimeRunArgs(engine: EngineInfo, options: RuntimeRunArgsOptions): string[] { const hostLoopback = engine.cli === 'podman' && engine.rootless && engine.podmanMajorVersion === 3; + const selinux = engine.cli === 'podman' && engine.selinux === true; return [ 'run', '--rm', @@ -97,6 +98,7 @@ export function runtimeRunArgs(engine: EngineInfo, options: RuntimeRunArgsOption options.containerName, '--detach', ...(hostLoopback ? ['--network', 'slirp4netns:allow_host_loopback=true'] : []), + ...(selinux ? ['--security-opt', 'label=disable'] : []), '-p', '3333:3333', '-p', @@ -105,7 +107,6 @@ export function runtimeRunArgs(engine: EngineInfo, options: RuntimeRunArgsOption `${options.hostSocketPath}:${RUNTIME_SOCKET_PATH}`, '-v', `${options.dataMount}:/data`, - ...(options.extraRunArgs ?? []), options.image, ]; } @@ -169,17 +170,6 @@ export function pullImage(image: string): void { execFileSync(CONTAINER_CLI, ['pull', '--platform', COMPATIBILITY_BUILD_PLATFORM, image], { stdio: 'inherit' }); } -/** - * `--security-opt label=disable` for the runtime container on a Podman host with SELinux enforcing - * (Fedora; the Fedora-based VM of Podman Desktop / `podman machine` on macOS and Windows): without it, - * SELinux keeps the container from the engine's socket bind-mounted into it - README's "Running with - * Podman" documents the same flag for users. Docker hosts and Podman hosts without SELinux get nothing - * extra, so the suite there runs `apify runtime start`'s own command verbatim (`runtimeRunArgs`). - */ -function engineSecurityOptions(engine: EngineInfo): string[] { - return engine.cli === 'podman' && engine.selinux ? ['--security-opt', 'label=disable'] : []; -} - export function startRuntimeContainer(tag: string, containerName: string): void { // A previous run that died before its `afterAll` (killed vitest, a CI job cancelled or timed out on a // persistent self-hosted runner) leaves a container of this name, and with it the fixed host ports @@ -199,7 +189,6 @@ export function startRuntimeContainer(tag: string, containerName: string): void containerName, hostSocketPath: hostEngineSocketPath(), dataMount: `${containerName}-data`, - extraRunArgs: engineSecurityOptions(engine), }); process.stdout.write(`${CONTAINER_CLI} ${args.join(' ')}\n`); execFileSync(CONTAINER_CLI, args, { stdio: 'inherit' }); diff --git a/test/unit/e2e-runtime-command.test.ts b/test/unit/e2e-runtime-command.test.ts index 9ff8d028..e2da81cc 100644 --- a/test/unit/e2e-runtime-command.test.ts +++ b/test/unit/e2e-runtime-command.test.ts @@ -35,23 +35,21 @@ describe("the e2e suite's runtime start command mirrors apify runtime start", () expect(runtimeRunArgs({ cli: 'podman', rootless: true }, options)).toEqual(plain); }); - it("the suite's own extra args (SELinux hosts) go right before the image, and nothing else moves", () => { + it('Podman with SELinux enforcing gets the socket mount un-labelled, after any network mode', () => { + const detached = ['run', '--rm', '--init', '--name', 'apify-actor-runtime', '--detach']; + const unlabelled = ['--security-opt', 'label=disable']; + expect(runtimeRunArgs({ cli: 'podman', rootless: false, selinux: true }, options)).toEqual([ + ...detached, + ...unlabelled, + ...common, + ]); expect( - runtimeRunArgs( - { cli: 'podman', rootless: false, selinux: true }, - { ...options, extraRunArgs: ['--security-opt', 'label=disable'] }, - ), - ).toEqual([ - 'run', - '--rm', - '--init', - '--name', - 'apify-actor-runtime', - '--detach', - ...common.slice(0, -1), - '--security-opt', - 'label=disable', - 'apify/actor-runtime:latest', + runtimeRunArgs({ cli: 'podman', rootless: true, podmanMajorVersion: 3, selinux: true }, options), + ).toEqual([...detached, '--network', 'slirp4netns:allow_host_loopback=true', ...unlabelled, ...common]); + // SELinux is a Podman-host concern; Docker reports nothing and gets nothing. + expect(runtimeRunArgs({ cli: 'docker', rootless: false, selinux: true }, options)).toEqual([ + ...detached, + ...common, ]); }); From 49202a572e2208ada0d5d622f83a8433aa214c0a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:30:18 +0000 Subject: [PATCH 13/21] ci: drive the e2e suite with apify-cli built from its actor-runtime branch The runtime and the CLI are developed together, but CI tested the runtime against the CLI published on npm, so a change on either side that broke the other only surfaced once released. A new `apify-cli` job checks out the CLI's `actor-runtime` branch and packs it the way `npm publish` would; every e2e job (hosted matrix, Linux arm64, macOS arm64) installs that package with `.github/scripts/install-apify-cli-package.sh` and points the suite at it through `ACTOR_RUNTIME_E2E_APIFY_CLI`. Locally the suite still defaults to the published CLI. With that CLI always available, the monorepo e2e file joins the hosted matrix instead of skipping itself everywhere. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/install-apify-cli-package.sh | 35 ++++++++++++ .github/workflows/ci.yml | 60 +++++++++++++++++++- requirements/test.md | 6 +- test/e2e/helpers/apify-cli.ts | 7 ++- test/e2e/monorepo-actor.test.ts | 6 +- 5 files changed, 107 insertions(+), 7 deletions(-) create mode 100755 .github/scripts/install-apify-cli-package.sh diff --git a/.github/scripts/install-apify-cli-package.sh b/.github/scripts/install-apify-cli-package.sh new file mode 100755 index 00000000..9fc8873f --- /dev/null +++ b/.github/scripts/install-apify-cli-package.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Installs a packed `apify-cli` tarball - `ci.yml`'s `apify-cli` job builds one from the CLI's +# `actor-runtime` branch, the unreleased CLI the runtime is developed against - into a directory of +# its own and points the e2e suite at it (`ACTOR_RUNTIME_E2E_APIFY_CLI`, `test/e2e/helpers/apify-cli.ts`), +# so every e2e job drives the runtime with that CLI rather than the one published on npm: the two are +# developed together, and this is what keeps them compatible. Local use: the same, with the export +# line printed at the end for the shell that runs the suite. +# +# Usage: install-apify-cli-package.sh [install-dir] +# install-dir defaults to `$RUNNER_TEMP/apify-cli` (or `$TMPDIR/apify-cli` outside Actions). +set -euo pipefail + +tgz=${1:?usage: install-apify-cli-package.sh [install-dir]} +dir=${2:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/apify-cli} +[ -f "$tgz" ] || { echo "No such file: $tgz" >&2; exit 2; } +tgz=$(cd "$(dirname "$tgz")" && pwd)/$(basename "$tgz") + +# A plain npm install of the tarball as a dependency of a throwaway package: the suite runs the entry +# script straight from `node_modules/apify-cli`, exactly the files `npm install -g apify-cli` would put +# there (the package's `files`, with its dependencies resolved from npm). +rm -rf "$dir" +mkdir -p "$dir" +cd "$dir" +npm init -y >/dev/null +npm install --no-audit --no-fund --loglevel=error "$tgz" + +entry=$dir/node_modules/apify-cli/dist/apify.js +[ -f "$entry" ] || { echo "The package did not install an entry script at $entry" >&2; exit 1; } +echo "Installed apify-cli $(node "$entry" --version) from $tgz" + +if [ -n "${GITHUB_ENV:-}" ]; then + echo "ACTOR_RUNTIME_E2E_APIFY_CLI=$entry" >> "$GITHUB_ENV" +else + echo "export ACTOR_RUNTIME_E2E_APIFY_CLI=$entry" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a19a12a2..312d67ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,12 @@ on: branches: [master, v2-poc-requirements] pull_request: +env: + # The apify-cli branch the runtime is developed against. Every e2e job below drives the runtime + # with the CLI built from it (the `apify-cli` job), not the one published on npm, so a change on + # either side that breaks the other shows up here, before either is released. + APIFY_CLI_REF: actor-runtime + jobs: checks: name: Build, lint, unit + integration tests @@ -25,8 +31,39 @@ jobs: - run: pnpm run format:check - run: pnpm test + apify-cli: + # The CLI the e2e jobs drive the runtime with, built once here and installed by each of them + # (`.github/scripts/install-apify-cli-package.sh`) - see `APIFY_CLI_REF` above. + name: Build apify-cli from its development branch + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + repository: apify/apify-cli + ref: ${{ env.APIFY_CLI_REF }} + - name: Record the CLI commit + run: echo "apify-cli \`$APIFY_CLI_REF\` @ $(git rev-parse HEAD) - $(git log -1 --format=%s)" >> "$GITHUB_STEP_SUMMARY" + # The CLI's own pnpm (its `packageManager`) and Node: the checkout is the CLI's repository. + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + # `pnpm pack` runs the CLI's `prepack` (its build included) and produces the same package + # `pnpm publish` would, the published `files` only - what `npm install -g apify-cli` gets. + - run: pnpm pack --out apify-cli.tgz + - uses: actions/upload-artifact@v4 + with: + name: apify-cli-package + path: apify-cli.tgz + retention-days: 1 + if-no-files-found: error + e2e: name: e2e ${{ matrix.file }} (apify-cli + ${{ matrix.engine }}) + needs: apify-cli # `podman-3.4` is the Podman 3.x leg: Ubuntu 22.04's stock Podman 3.4.4, which the ubuntu-22.04 # runner image ships preinstalled - the CNI generation, with no usable user-defined networks. runs-on: ${{ matrix.engine == 'podman-3.4' && 'ubuntu-22.04' || 'ubuntu-latest' }} @@ -48,6 +85,7 @@ jobs: - actor-dev-loop - debug-mode - dev-folder-bind-mount + - monorepo-actor - nonstandard-actor - browser-view-ts - browser-view-py @@ -63,6 +101,12 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@v4 + with: + name: apify-cli-package + path: ${{ runner.temp }}/apify-cli-package + - name: Install the apify-cli package built from its ${{ env.APIFY_CLI_REF }} branch + run: .github/scripts/install-apify-cli-package.sh "$RUNNER_TEMP/apify-cli-package/apify-cli.tgz" # Rootless Podman serves its Docker-compatible API on a socket only when asked to. The suite # drives Podman through `CONTAINER_CLI=podman` and mounts the socket named by `DOCKER_HOST` # into the runtime container. @@ -78,7 +122,7 @@ jobs: echo "DOCKER_HOST=unix://$sock" >> "$GITHUB_ENV" # The suite manages the engine itself against the runner's daemon: it pre-pulls the # Actor base images, builds the runtime image, starts the runtime container with - # the host engine's socket, and drives it with stock apify-cli via npx. + # the host engine's socket, and drives it with the apify-cli installed above. - run: pnpm exec vitest run test/e2e/${{ matrix.file }}.test.ts # The e2e's runtime container is normally removed by the suite's afterAll; on # failure it is left running, so its server-side view of any failed request @@ -93,6 +137,7 @@ jobs: e2e-arm64: name: e2e actor-dev-loop (apify-cli + docker on arm64) + needs: apify-cli # The Linux arm64 coverage in CI (`e2e-macos-arm64` below is the other): `build-image.yml` builds # multi-arch but runs only on dispatch or release, so an arm64 break would otherwise surface at # release time. Native, so nothing here is emulated. @@ -106,6 +151,12 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@v4 + with: + name: apify-cli-package + path: ${{ runner.temp }}/apify-cli-package + - name: Install the apify-cli package built from its ${{ env.APIFY_CLI_REF }} branch + run: .github/scripts/install-apify-cli-package.sh "$RUNNER_TEMP/apify-cli-package/apify-cli.tgz" # Only this file. The browser-based cases need the amd64 emulation `startBuild`'s fallback # builds under, and nothing here installs binfmt/QEMU - `e2e-macos-arm64` runs them, under # Rosetta. @@ -135,6 +186,7 @@ jobs: # Self-hosted, so never for a fork's pull request: `pull_request` runs the head branch's code, # and the runner is a long-lived Apify machine. Pushes to master and same-repo PRs only. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + needs: apify-cli runs-on: [self-hosted, macOS, ARM64] timeout-minutes: 120 strategy: @@ -171,6 +223,12 @@ jobs: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@v4 + with: + name: apify-cli-package + path: ${{ runner.temp }}/apify-cli-package + - name: Install the apify-cli package built from its ${{ env.APIFY_CLI_REF }} branch + run: .github/scripts/install-apify-cli-package.sh "$RUNNER_TEMP/apify-cli-package/apify-cli.tgz" # Every e2e file, one after another, on this engine. - run: pnpm run test:e2e - name: Dump runtime container logs on failure diff --git a/requirements/test.md b/requirements/test.md index e7787186..df361685 100644 --- a/requirements/test.md +++ b/requirements/test.md @@ -6,6 +6,7 @@ - CI (GitHub Actions) runs on every pull request and on pushes to the main branches: build, lint, format check, and all test layers, with the mandatory CLI-only e2e suite below executing against a real Docker daemon. A missing daemon fails the CI job - the e2e suite never silently skips. - CI runs each e2e file as its own job, in parallel; locally the files run one after another (each starts a runtime container on the fixed ports). +- CI drives every e2e job with `apify-cli` built from the CLI's `actor-runtime` branch - the unreleased CLI the runtime is developed against - rather than the one published on npm: one job packs that branch the way `npm publish` would, and each e2e job installs the package and points the suite at it (`ACTOR_RUNTIME_E2E_APIFY_CLI`). A change on either side that breaks the other fails CI here, before either is released. Locally the suite defaults to the published CLI. - Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the whole e2e suite on a self-hosted macOS arm64 runner - the platform most developers use the runtime from - once against Docker and once against Podman, each engine in a Linux VM with the host's home directory shared into it, the way Docker Desktop and Podman Desktop work on a Mac. That runner is a clean, persistent Mac with no container engine of its own, so each job installs one for itself in user space (Lima's VM plus Colima and the Docker CLI, or Lima's Podman template plus the podman remote client; pinned and checksum-verified, no sudo), logs it into Docker Hub with the repository's service-account secrets so the per-job image pulls count against the account rather than the machine's anonymous per-IP limit, runs the suite, then removes everything it left behind (its temp dirs, and the engine with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. # Mandatory end-to-end tests @@ -63,5 +64,6 @@ Test case must verify full Actor development flow: - Both Actors of `samples/actor_monorepo` push, build and run with the shared Dockerfile, input schema and package. - A build fails or succeeds as the platform's Git build of the same repository would. -- Runs only with a locally built CLI (`ACTOR_RUNTIME_E2E_APIFY_CLI`) until monorepo support is published, - and is not in CI until then. `ACTOR_RUNTIME_E2E_IMAGE` tests an already built runtime image. +- Runs only with a locally built CLI (`ACTOR_RUNTIME_E2E_APIFY_CLI`) until monorepo support is published; + CI, which builds the CLI from its `actor-runtime` branch, runs it like every other file. + `ACTOR_RUNTIME_E2E_IMAGE` tests an already built runtime image. diff --git a/test/e2e/helpers/apify-cli.ts b/test/e2e/helpers/apify-cli.ts index 2c5adbd0..8de1326c 100644 --- a/test/e2e/helpers/apify-cli.ts +++ b/test/e2e/helpers/apify-cli.ts @@ -4,8 +4,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; /** - * Set to a built `apify-cli` entry script (`/dist/apify.js`) to drive the suite with - * that CLI instead of the published one - for runtime features whose CLI side is not released yet. + * Set to a built `apify-cli` entry script (`/dist/apify.js`, or the installed + * package's `node_modules/apify-cli/dist/apify.js`) to drive the suite with that CLI instead of the + * published one - for runtime features whose CLI side is not released yet. CI always sets it, to the + * CLI built from its `actor-runtime` branch (`ci.yml`'s `apify-cli` job): the runtime and the CLI are + * developed together, and the suite is what keeps them compatible. */ const LOCAL_APIFY_CLI_ENV_VAR = 'ACTOR_RUNTIME_E2E_APIFY_CLI'; diff --git a/test/e2e/monorepo-actor.test.ts b/test/e2e/monorepo-actor.test.ts index ef39cc72..1c8b097e 100644 --- a/test/e2e/monorepo-actor.test.ts +++ b/test/e2e/monorepo-actor.test.ts @@ -2,7 +2,8 @@ * Monorepo Actors (`actor-driver.md`'s "Monorepo Actors") through `apify-cli` only: both Actors of * `samples/actor_monorepo` share one Dockerfile, one input schema and one package outside their own folders, * so each builds only from the whole Docker context `apify push` sends. Needs an `apify-cli` with monorepo - * support - until it is published, point `ACTOR_RUNTIME_E2E_APIFY_CLI` at a built one (`helpers/apify-cli.ts`). + * support: CI drives the suite with the CLI built from its `actor-runtime` branch, which has it; locally, + * until it is published, point `ACTOR_RUNTIME_E2E_APIFY_CLI` at a built one (`helpers/apify-cli.ts`). */ import { execFileSync } from 'node:child_process'; import { appendFileSync, chmodSync, cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; @@ -44,7 +45,8 @@ interface GreetingItem { actorPath: string; } -// Until a published apify-cli supports monorepo Actors, this file runs only against a locally built one. +// Until a published apify-cli supports monorepo Actors, this file runs only against a locally built one - +// which CI always provides, so there it never skips. describe.skipIf(!process.env.ACTOR_RUNTIME_E2E_APIFY_CLI)('monorepo Actors via apify-cli (requires Docker)', () => { let isolatedApifyHome: string; From 3f1aa3e62d986a7330185a14f0ac6847430c29b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 09:48:54 +0000 Subject: [PATCH 14/21] ci(macos): run the Podman legs on the runner's installed podman, rootless and rootful The runner now has Podman installed from podman.io's package, the install a developer has. The macOS Podman leg therefore no longer brings Lima's Podman template and a remote client of its own; it creates the installed podman's own machine (`podman machine init`, named `actor-runtime-e2e`) per job and removes it at the end - the same Fedora CoreOS image, Virtualization.framework VM, virtiofs home share and Rosetta a developer's machine is. Two legs: rootless, the default a developer gets, and rootful, so both modes `apify runtime start` handles are covered. The suite mounts the in-VM socket into the runtime container; for Podman the script now resolves it the way the CLI does (`podman info --format '{{.Host.RemoteSocket.Path}}'`, rootless: `/run/user//podman/podman.sock`) and exports it as `DOCKER_HOST`. `TMPDIR` is pinned to the engine dir before any podman call, since Podman keeps the machine's host-side sockets under `$TMPDIR/podman` and resolves that per invocation. Docker stays as it was: the open-source stack brought along per job. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-container-engine.sh | 280 +++++++++++++--------- .github/workflows/ci.yml | 30 ++- requirements/test.md | 2 +- 3 files changed, 187 insertions(+), 125 deletions(-) diff --git a/.github/scripts/macos-container-engine.sh b/.github/scripts/macos-container-engine.sh index 8d6db8fd..2c435dff 100755 --- a/.github/scripts/macos-container-engine.sh +++ b/.github/scripts/macos-container-engine.sh @@ -1,43 +1,54 @@ #!/usr/bin/env bash -# Puts a container engine on a clean macOS arm64 host for the e2e suite, and takes it away again - -# for the self-hosted runner in `ci.yml`, which has no Docker, Podman or OrbStack of its own. +# Puts a container engine on the self-hosted macOS arm64 runner in `ci.yml` for the e2e suite, and takes +# it away again - a VM per job, started from nothing and deleted at the end, so every job meets a fresh +# engine and leaves none behind. # -# Everything is user space: no sudo, no Homebrew, no GUI login session, nothing under /usr/local or -# /Applications. The pieces, all pinned, all downloaded straight from their projects' releases and -# verified against the checksums those releases publish: -# - Lima - runs a Linux VM on Apple's Virtualization.framework (`vz`, macOS 13+); both engines live in -# one such VM, which is also how Docker Desktop, OrbStack and Podman Desktop work on a Mac -# - `docker`: the static Docker CLI (download.docker.com), the buildx CLI plugin (the runtime's -# Dockerfile needs BuildKit: `FROM --platform=$BUILDPLATFORM`, and Docker CLI >= 23 only builds with -# BuildKit through this plugin), and Colima, which provisions the Docker daemon in the Lima VM -# - `podman`: the podman remote client (containers/podman's `podman-remote-release-darwin_arm64.zip`) -# talking to Lima's own `podman-rootful` template - Fedora's current Podman, its API socket at -# `/run/podman/podman.sock` inside the VM, forwarded to a host socket the client is pointed at +# Docker: the runner has no Docker of its own (Docker Desktop is the commercial product; everything here +# is the Apache-licensed engine and CLI), so each job brings the whole stack, user space, no sudo, no +# Homebrew, nothing under /usr/local or /Applications - the pieces all pinned, downloaded straight from +# their projects' releases and verified against the checksums those releases publish: +# - Lima - runs a Linux VM on Apple's Virtualization.framework (`vz`, macOS 13+), the way Docker +# Desktop, OrbStack and Podman's own machine do +# - the static Docker CLI (download.docker.com), the buildx CLI plugin (the runtime's Dockerfile +# needs BuildKit: `FROM --platform=$BUILDPLATFORM`, and Docker CLI >= 23 only builds with BuildKit +# through this plugin), and Colima, which provisions the Docker daemon in the Lima VM # -# All of it lives under one directory, `$E2E_ENGINE_DIR` (default `$HOME/.actor-runtime-e2e`): the -# binaries, Colima's state (`COLIMA_HOME`, which also places Lima's `LIMA_HOME` under it), the Podman -# instance's `LIMA_HOME`, an isolated `DOCKER_CONFIG` for the docker context and the plugin, and a -# `tmp` dir the job's `TMPDIR` is pointed at (below). `teardown` deletes the VM and that directory, -# plus Lima's image download cache in `~/Library/Caches`, leaving the machine as it was found. The -# runner user's own `~/.docker`, `~/.colima`, `~/.lima` and `~/.config/containers` are never touched. +# Podman: installed on the runner permanently, by hand, from podman.io's installer package - the same +# install a developer has (Podman Desktop bundles that package; Homebrew builds the same pieces). It is +# only the client and the VM tooling; no VM runs until a job makes one. Each job creates its own +# `podman machine` (named `actor-runtime-e2e`, so nothing else of the runner user's is ever touched), +# rootless (`podman-rootless`, the default a developer gets) or rootful (`podman-rootful`), and removes +# it in `teardown`. That is the same Fedora CoreOS image, Virtualization.framework VM, virtiofs home +# share and Rosetta a developer's machine is, so what passes here passes on their Mac. The machine's +# state lives where Podman keeps it (`~/.local/share/containers/podman/machine`, the connections in +# `~/.config/containers`); `podman machine rm` takes all of it but the downloaded machine image, kept as +# a cache (~1 GB) so the next job's `init` is quick. +# +# The Docker stack lives under one directory, `$E2E_ENGINE_DIR` (default `$HOME/.actor-runtime-e2e`): +# the binaries, Colima's state (`COLIMA_HOME`, which also places Lima's `LIMA_HOME` under it), an +# isolated `DOCKER_CONFIG` for the docker context and the plugin, and a `tmp` dir the job's `TMPDIR` is +# pointed at (below) - used by both engines, as is the isolated `REGISTRY_AUTH_FILE` for Podman's +# Docker Hub login. `teardown` deletes the VM and that directory, plus Lima's image download cache in +# `~/Library/Caches`. The runner user's own `~/.docker`, `~/.colima` and `~/.lima` are never touched. # # The directory is deliberately short and directly under `$HOME`, not under `$RUNNER_TEMP`: Lima puts # unix sockets in the instance dir (`//ssh.sock.<16 digits>`), and macOS caps a # socket path at 104 characters - the runner's `_work/_temp/...` path pushed that to 112 and Lima # refused to start. # -# Host directories inside the VM: `$HOME` is mounted, writable, at the same path (virtiofs) - the way -# Docker Desktop shares `/Users` by default. The dev-folder e2e files need it: the runtime probes the -# registered folder through the daemon's view of `/` and bind-mounts it into the Actor container, so -# the path must exist inside the VM. `TMPDIR` is pointed at `$engine_dir/tmp` for the same reason: -# the suite's `mkdtemp` folders (Node's `os.tmpdir()` honors `TMPDIR`) have to be under the mount, -# and macOS's per-user default (`/var/folders/...`) is not. +# Host directories inside the VM: `$HOME` is mounted, writable, at the same path (virtiofs) - Colima +# is told so (`--mount`), Podman's machine does it by default (`/Users`), the way Docker Desktop shares +# `/Users`. The dev-folder e2e files need it: the runtime probes the registered folder through the +# daemon's view of `/` and bind-mounts it into the Actor container, so the path must exist inside the +# VM. `TMPDIR` is pointed at `$engine_dir/tmp` for the same reason: the suite's `mkdtemp` folders +# (Node's `os.tmpdir()` honors `TMPDIR`) have to be under the mount, and macOS's per-user default +# (`/var/folders/...`) is not under `$HOME`. # # amd64 emulation: the Playwright sample Actors' base images have no arm64 build, so the browser-view # files build and run them as amd64 (`build-platform.ts`'s fallback). With Rosetta on the host the VM # gets it (`--vz-rosetta` / `--rosetta`), which is fast; without it the fallback is QEMU user-mode -# emulation registered in the VM (`tonistiigi/binfmt` for Docker, `qemu-user-static` for Podman), -# which is slow but works. Installing Rosetta is attempted once; it may need admin rights. +# emulation registered in the VM (`tonistiigi/binfmt`), which is slow but works. Installing Rosetta is +# attempted once; it may need admin rights. # # Docker Hub: each job starts from an empty engine and pulls every base image again, and Docker Hub's # anonymous pull limit is per IP - a day of runs on one Mac exhausts it ("toomanyrequests"). With @@ -46,16 +57,20 @@ # the credentials land in the isolated `DOCKER_CONFIG` / `REGISTRY_AUTH_FILE` under the engine dir, # never in the runner user's own config, and go with the teardown. Missing credentials only warn. # -# Why no `DOCKER_HOST`: the suite mounts `hostEngineSocketPath()` into the runtime container, and with -# no `DOCKER_HOST` that is `/var/run/docker.sock` (docker) or `/run/podman/podman.sock` (podman) - -# which inside the VM, where the container runs, IS the daemon's socket. The host-side forwarded -# sockets would not work there: a bind mount of a macOS unix socket does not cross into the VM. The -# CLIs reach the daemon another way: the docker context Colima activates (`--activate`, the default), -# and `CONTAINER_HOST` for the podman remote client - neither is read by the suite. +# The socket the runtime container gets: the suite mounts `hostEngineSocketPath()` into it - a path the +# engine resolves, so it must be the daemon's socket as seen inside the VM, where the container runs; +# the host-side forwarded sockets would not work there, a bind mount of a macOS unix socket does not +# cross into the VM. Docker: no `DOCKER_HOST`, and the suite falls back to `/var/run/docker.sock`, +# which inside the VM IS the daemon's socket; the docker CLI reaches the daemon through the context +# Colima activates (`--activate`, the default). Podman: `DOCKER_HOST` is set to what +# `podman info --format '{{.Host.RemoteSocket.Path}}'` reports - the in-VM socket, rootful +# (`/run/podman/podman.sock`) or rootless (`/run/user//podman/podman.sock`), the very query +# `apify runtime start` makes; the podman client reaches the VM through the machine's own connection +# (`CONTAINER_CONNECTION`), which the suite never reads. # -# Usage: macos-container-engine.sh install docker|podman +# Usage: macos-container-engine.sh install docker|podman-rootless|podman-rootful # macos-container-engine.sh teardown -# install downloads, verifies, starts the VM, smoke-tests the socket and home mounts, and exports +# install sets the engine up, starts the VM, smoke-tests the socket and home mounts, and exports # the engine's env (`CONTAINER_CLI`, `TMPDIR`, ...) and PATH additions to the job # (`$GITHUB_ENV` / `$GITHUB_PATH`) so every later step sees the same engine # teardown stops and deletes whichever VM exists and removes everything `install` created; safe to @@ -70,7 +85,6 @@ DOCKER_VERSION=29.8.1 BUILDX_VERSION=v0.37.1 LIMA_VERSION=2.2.0 COLIMA_VERSION=v0.10.3 -PODMAN_VERSION=v6.1.2 engine_dir=${E2E_ENGINE_DIR:-$HOME/.actor-runtime-e2e} engine_dir=${engine_dir%/} @@ -80,9 +94,12 @@ tmp_dir=$engine_dir/tmp export COLIMA_HOME=$engine_dir/colima export DOCKER_CONFIG=$engine_dir/docker-config export REGISTRY_AUTH_FILE=$engine_dir/podman-auth.json -# Lima home for the standalone (podman) instance. Colima ignores it and uses `$COLIMA_HOME/_lima`. -podman_lima_home=$engine_dir/limahome -PODMAN_INSTANCE=podman +# The podman machine this script makes and removes; nothing else of the runner user's Podman is touched. +PODMAN_MACHINE=actor-runtime-e2e +# Where podman.io's installer package puts the client, then Homebrew's places, in case `PATH` in the +# runner's service environment has none of them (the package adds its own through /etc/paths.d, which +# only login shells read). +PODMAN_CANDIDATES="/opt/podman/bin /usr/local/bin /opt/homebrew/bin" export PATH=$bin_dir:$lima_bin_dir:$PATH die() { @@ -177,9 +194,9 @@ smoke_tests() { else "$cli" info --format 'Arch={{.Architecture}} OS={{.OperatingSystem}} Server={{.ServerVersion}}' fi - # What the suite's `startRuntimeContainer` adds on a Podman host with SELinux enforcing (Fedora, as - # Lima's Podman template and Podman Desktop's machine both are), and what README's "Running with - # Podman" tells users to add: without it SELinux keeps a container from the socket mounted into it. + # What `apify runtime start` and the suite's `runtimeRunArgs` add on a Podman host with SELinux + # enforcing (Fedora CoreOS, as the podman machine is), and what README's "Running with Podman" tells + # users to add: without it SELinux keeps a container from the socket mounted into it. # A plain string, expanded unquoted below: macOS's bash 3.2 treats an empty array expansion as an # unbound variable under `set -u`. local secopt= @@ -228,6 +245,13 @@ export_env() { fi } +# export_late NAME=value...: for what is only known once the VM runs (the Podman socket path). +export_late() { + if [ -n "${GITHUB_ENV:-}" ]; then + printf '%s\n' "$@" >> "$GITHUB_ENV" + fi +} + install_lima() { echo "::group::Install Lima $LIMA_VERSION" local dl=$1 @@ -244,7 +268,9 @@ install_lima() { } install_docker() { - local dl=$1 + local dl=$engine_dir/downloads + mkdir -p "$bin_dir" "$DOCKER_CONFIG/cli-plugins" "$COLIMA_HOME" "$dl" + install_lima "$dl" echo "::group::Install Docker CLI $DOCKER_VERSION" # The static bundle is served over TLS from Docker's own host and publishes no separate checksum; @@ -309,96 +335,111 @@ install_docker() { echo "Docker engine ready under $engine_dir." } -install_podman() { - local dl=$1 - - echo "::group::Install podman remote client $PODMAN_VERSION" - local podman_asset=podman-remote-release-darwin_arm64.zip - local podman_base="https://github.com/containers/podman/releases/download/$PODMAN_VERSION" - fetch "$podman_base/$podman_asset" "$dl/$podman_asset" - fetch "$podman_base/shasums" "$dl/podman-shasums" - verify "$dl/$podman_asset" "$(sha_for "$dl/podman-shasums" "$podman_asset")" - mkdir -p "$dl/podman" - unzip -q "$dl/$podman_asset" -d "$dl/podman" - # The zip unpacks to `podman-/usr/bin/podman` (plus docs and the mac helper, unused here). - local podman_bin - podman_bin=$(find "$dl/podman" -type f -name podman -path '*/bin/*' | head -n 1) - [ -n "$podman_bin" ] || die "The podman remote client zip contains no bin/podman." - command install -m 0755 "$podman_bin" "$bin_dir/podman" - echo "::endgroup::" +# find_podman: the installed client, on PATH or in one of the usual install locations; its directory +# goes on the job's PATH so every later step (the suite, the log dump, `teardown`) finds the same one. +find_podman() { + local dir + if ! command -v podman >/dev/null 2>&1; then + for dir in $PODMAN_CANDIDATES; do + if [ -x "$dir/podman" ]; then + export PATH=$dir:$PATH + break + fi + done + fi + command -v podman >/dev/null 2>&1 || + die "No podman on this runner. Install it from podman.io's installer package (podman-installer-macos-arm64.pkg) as an admin; this script makes and removes the machine itself." + dir=$(dirname "$(command -v podman)") + if [ -n "${GITHUB_PATH:-}" ]; then + echo "$dir" >> "$GITHUB_PATH" + fi + echo "Using $(command -v podman): $(podman --version)" +} - rm -rf "$dl" - export LIMA_HOME=$podman_lima_home - mkdir -p "$LIMA_HOME" - local sock="$LIMA_HOME/$PODMAN_INSTANCE/sock/podman.sock" - export CONTAINER_HOST="unix://$sock" - export_env "CONTAINER_CLI=podman" "LIMA_HOME=$LIMA_HOME" "CONTAINER_HOST=$CONTAINER_HOST" +# install_podman rootless|rootful +install_podman() { + local mode=$1 + # Podman keeps the machine's host-side sockets and pids under `$TMPDIR/podman`, resolved on every + # invocation, not stored: this script and the job's later steps (which get `TMPDIR=$tmp_dir` from + # `export_env`) must agree on it, or `podman machine rm` in `teardown` would look in the wrong + # place and leave the VM running. Short enough for macOS's socket path cap, being under `$HOME`. + mkdir -p "$tmp_dir" + export TMPDIR=$tmp_dir + find_podman + # The machine's two connections: `` as the VM's user, `-root` as root. The client is + # pinned to the one for the mode, rather than relying on which `init` makes the default. + local connection=$PODMAN_MACHINE + [ "$mode" = rootful ] && connection=$PODMAN_MACHINE-root + export CONTAINER_CONNECTION=$connection + export_env "CONTAINER_CLI=podman" "CONTAINER_CONNECTION=$connection" - echo "::group::Start the Podman VM" + echo "::group::Create the Podman machine ($mode)" vm_size ensure_rosetta || true - # Lima's own template: Fedora with Podman from dnf, rootful, `podman.socket` on - # `/run/podman/podman.sock`, forwarded to `/sock/podman.sock` on the host. - # `--mount-writable` turns the template's default read-only `~` mount writable. `--tty=false`: no - # interactive "proceed with this configuration?" prompt. - limactl start \ - --tty=false \ - --name="$PODMAN_INSTANCE" \ - --vm-type vz \ - --mount-type virtiofs \ - --mount-writable \ + # `--memory` is MiB. `--disk-size` is a sparse maximum. Default mounts (`/Users`, `/private`, + # `/var/folders`) cover `$HOME`. `--rosetta` is the applehv provider's; off, the VM has no amd64 + # handler and the fallback below registers QEMU ones. + podman machine init \ --cpus "$vm_cpus" \ - --memory "$vm_memory" \ - --disk 60 \ - ${rosetta:+--rosetta} \ - template:podman-rootful - limactl list - # The socket forward is set up by Lima's host agent once the guest socket exists; give it a moment. - local i - for i in $(seq 1 60); do - [ -S "$sock" ] && podman info >/dev/null 2>&1 && break - sleep 1 - done - podman info >/dev/null || die "podman cannot reach the VM's API socket through $sock." + --memory "$((vm_memory * 1024))" \ + --disk-size 60 \ + --rosetta="$([ -n "$rosetta" ] && echo true || echo false)" \ + $([ "$mode" = rootful ] && echo --rootful) \ + "$PODMAN_MACHINE" + podman machine start "$PODMAN_MACHINE" + podman machine list + podman system connection list + podman info --format 'Connection OK: {{.Host.Hostname}} rootless={{.Host.Security.Rootless}} selinux={{.Host.Security.SELinuxEnabled}} podman={{.Version.Version}}' || + die "podman cannot reach the machine through connection '$connection'." registry_login podman + # The in-VM socket, as `apify runtime start` resolves it for Podman; exported as `DOCKER_HOST` so + # the suite mounts that path (see the header). Rootless: `/run/user//podman/podman.sock`. + local sock + sock=$(podman info --format '{{.Host.RemoteSocket.Path}}') + sock=${sock#unix://} + [ -n "$sock" ] || die "podman info reports no remote socket path." + echo "Engine socket inside the VM: $sock" + export DOCKER_HOST="unix://$sock" + export_late "DOCKER_HOST=$DOCKER_HOST" # For reading a failed socket smoke test below: the socket's SELinux label and the enforcing mode. - limactl shell "$PODMAN_INSTANCE" -- sh -c 'getenforce; ls -laZ /run/podman/' || true + podman machine ssh "$PODMAN_MACHINE" -- sh -c "getenforce; ls -laZ $(dirname "$sock")/" || true if [ -z "$rosetta" ]; then - limactl shell "$PODMAN_INSTANCE" sudo dnf install -y qemu-user-static-x86 || - warn "Could not install QEMU amd64 emulation in the VM." + # QEMU user-mode handlers for foreign architectures, registered in the VM's kernel (binfmt_misc) + # by the VM's root, whichever mode the machine is in. + podman machine ssh "$PODMAN_MACHINE" -- sudo podman run --privileged --rm docker.io/tonistiigi/binfmt --install amd64 || + warn "Could not register QEMU amd64 emulation in the VM." fi echo "::endgroup::" - smoke_tests podman /run/podman/podman.sock - echo "Podman engine ready under $engine_dir." + smoke_tests podman "$sock" + echo "Podman engine ready: machine $PODMAN_MACHINE ($mode)." } engine_install() { local engine=${1:-} case "$engine" in - docker | podman) ;; - *) die "Usage: $0 install docker|podman" ;; + docker | podman-rootless | podman-rootful) ;; + *) die "Usage: $0 install docker|podman-rootless|podman-rootful" ;; esac [ "$(uname -s)" = Darwin ] || die "This script is for macOS; this is $(uname -s)." [ "$(uname -m)" = arm64 ] || die "This script is for Apple Silicon (arm64); this is $(uname -m)." local macos_major macos_major=$(sw_vers -productVersion | cut -d. -f1) - [ "$macos_major" -ge 13 ] || die "Lima's vz driver needs macOS 13 or newer; this is $(sw_vers -productVersion)." + [ "$macos_major" -ge 13 ] || die "Virtualization.framework VMs need macOS 13 or newer; this is $(sw_vers -productVersion)." # A previous job that died without its teardown step (the runner losing contact with GitHub # mid-suite leaves the job's later steps unrun) leaves its VM running and its host agent holding the # forwarded ports. Stop and delete all of that first, with the binaries it left behind, rather than # `rm -rf` the directory from under a live VM. - if [ -d "$engine_dir" ]; then - echo "Found a leftover engine under $engine_dir; removing it first." - engine_teardown - fi - mkdir -p "$bin_dir" "$DOCKER_CONFIG/cli-plugins" "$COLIMA_HOME" "$tmp_dir" - local dl=$engine_dir/downloads - mkdir -p "$dl" + echo "Removing whatever an earlier job left behind first." + engine_teardown + mkdir -p "$tmp_dir" - install_lima "$dl" - "install_$engine" "$dl" + case "$engine" in + docker) install_docker ;; + podman-rootless) install_podman rootless ;; + podman-rootful) install_podman rootful ;; + esac } engine_teardown() { @@ -406,15 +447,32 @@ engine_teardown() { colima stop --force 2>/dev/null || true colima delete --force 2>/dev/null || true fi - if command -v limactl >/dev/null 2>&1 && [ -d "$podman_lima_home/$PODMAN_INSTANCE" ]; then - LIMA_HOME=$podman_lima_home limactl stop --tty=false --force "$PODMAN_INSTANCE" 2>/dev/null || true - LIMA_HOME=$podman_lima_home limactl delete --tty=false --force "$PODMAN_INSTANCE" 2>/dev/null || true - fi # Lima's host agents and the ssh sessions they keep to the VMs, matched by their own names plus the # engine dir on their command line - Lima normally ends them; this is for a half-started VM. Not a # bare `pkill -f "$engine_dir"`: that also matches any shell whose command line mentions the dir. pkill -f "limactl.*$engine_dir" 2>/dev/null || true pkill -f "ssh.*$engine_dir" 2>/dev/null || true + # The podman machine, if the client is here and the machine exists (a leftover or this job's own): + # `rm -f` stops it first and drops its connections. Before the engine dir goes: the machine's + # host-side sockets live under `$tmp_dir` (see `install_podman`), and `rm` needs them to stop it. + # The machine image cache is kept, on purpose. + local dir + if ! command -v podman >/dev/null 2>&1; then + for dir in $PODMAN_CANDIDATES; do + [ -x "$dir/podman" ] && export PATH=$dir:$PATH && break + done + fi + if command -v podman >/dev/null 2>&1; then + mkdir -p "$tmp_dir" + export TMPDIR=$tmp_dir + if podman machine inspect "$PODMAN_MACHINE" >/dev/null 2>&1; then + echo "Removing podman machine $PODMAN_MACHINE." + podman machine rm -f "$PODMAN_MACHINE" || warn "Could not remove podman machine $PODMAN_MACHINE." + fi + # The VM and its network helper, should `rm` have lost track of them (a job killed mid-start). + pkill -f "vfkit.*$PODMAN_MACHINE" 2>/dev/null || true + pkill -f "gvproxy.*$PODMAN_MACHINE" 2>/dev/null || true + fi rm -rf "$engine_dir" # Lima keeps the downloaded VM images here, outside LIMA_HOME. Removed for a fully clean machine; a # runner that stays up between jobs could keep it and skip the ~700 MB download next time. @@ -426,7 +484,7 @@ case "${1:-}" in install) engine_install "${2:-}" ;; teardown) engine_teardown ;; *) - echo "Usage: $0 install docker|podman | teardown" >&2 + echo "Usage: $0 install docker|podman-rootless|podman-rootful | teardown" >&2 exit 2 ;; esac diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 312d67ad..6893e3f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,14 +172,16 @@ jobs: name: e2e all files (apify-cli + ${{ matrix.engine }} on macOS arm64, self-hosted) # The leg on the platform most developers run the runtime on: an Apple Silicon Mac, with the # engine in a Linux VM, the host's `$HOME` shared into it, and the socket the runtime container - # gets mounted being the VM's, not a kernel socket of the host. The runner is a clean Mac - no - # Docker, Podman or OrbStack - so each job brings its own engine (user space, no sudo; - # `.github/scripts/macos-container-engine.sh`) and removes it again when done: Docker via Colima, - # Podman via Lima's own template. It is a persistent self-hosted machine, so unlike every hosted - # leg above it is also cleaned before and after. + # gets mounted being the VM's, not a kernel socket of the host. Each job starts its own VM and + # removes it when done (`.github/scripts/macos-container-engine.sh`): Docker via Colima, which + # the job also brings along (the runner has no Docker; Docker Desktop is the commercial product), + # and Podman as the runner's installed `podman` makes it - its own `podman machine`, the one a + # developer has - once rootless, the default a developer gets, and once rootful, so both of the + # modes `apify runtime start` handles are covered. It is a persistent self-hosted machine, so + # unlike every hosted leg above it is also cleaned before and after. # # One job per engine running every e2e file in turn (`test:e2e` serializes them: fixed host - # ports), rather than one job per file as above: the engine install is per job, and there is + # ports), rather than one job per file as above: the engine setup is per job, and there is # one runner, so jobs queue rather than parallelize anyway. The browser-view files pull ~2 GB # base images and run Chrome under amd64 emulation, hence the wide budget. # @@ -194,19 +196,20 @@ jobs: matrix: engine: - docker - - podman + - podman-rootless + - podman-rootful steps: - uses: actions/checkout@v4 - # Downloads the pinned pieces into one directory under the runner user's home (removed again - # in the last step), starts the VM and smoke-tests the socket and home mounts the suite relies - # on. Exports the engine's env (`CONTAINER_CLI`, `TMPDIR`, ...) to the job; it deliberately - # does not set `DOCKER_HOST` (see the script for why). + # Starts the VM (for Docker: after downloading the pinned pieces into one directory under the + # runner user's home, removed again in the last step) and smoke-tests the socket and home + # mounts the suite relies on. Exports the engine's env (`CONTAINER_CLI`, `TMPDIR`, and for + # Podman the in-VM socket as `DOCKER_HOST`) to the job - see the script's header. # The Docker Hub credentials are the Apify service account's, the same repository secrets # `release.yml` publishes with: every job here starts from an empty engine and pulls every base # image again, and Docker Hub's anonymous limit is per IP, which one Mac exhausts in a day of # runs. Logged in, pulls count against the account. Absent (a fork has no secrets, and the job # does not run for forks anyway), the script warns and pulls anonymously. - - name: Install a ${{ matrix.engine }} engine (Lima VM, user space) + - name: Start a ${{ matrix.engine }} engine in a VM env: DOCKERHUB_USERNAME: ${{ secrets.APIFY_SERVICE_ACCOUNT_DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_DOCKERHUB_TOKEN }} @@ -244,7 +247,8 @@ jobs: if: always() run: .github/scripts/e2e-host-cleanup.sh # Stops and deletes the VM and removes the binaries, the engines' state, the isolated docker - # config and Lima's image cache - the machine is left as the job found it. + # config and Lima's image cache - the machine is left as the job found it (the installed + # podman and its machine image cache included). - name: Remove the container engine if: always() run: .github/scripts/macos-container-engine.sh teardown diff --git a/requirements/test.md b/requirements/test.md index df361685..61e6c7ce 100644 --- a/requirements/test.md +++ b/requirements/test.md @@ -7,7 +7,7 @@ - CI (GitHub Actions) runs on every pull request and on pushes to the main branches: build, lint, format check, and all test layers, with the mandatory CLI-only e2e suite below executing against a real Docker daemon. A missing daemon fails the CI job - the e2e suite never silently skips. - CI runs each e2e file as its own job, in parallel; locally the files run one after another (each starts a runtime container on the fixed ports). - CI drives every e2e job with `apify-cli` built from the CLI's `actor-runtime` branch - the unreleased CLI the runtime is developed against - rather than the one published on npm: one job packs that branch the way `npm publish` would, and each e2e job installs the package and points the suite at it (`ACTOR_RUNTIME_E2E_APIFY_CLI`). A change on either side that breaks the other fails CI here, before either is released. Locally the suite defaults to the published CLI. -- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the whole e2e suite on a self-hosted macOS arm64 runner - the platform most developers use the runtime from - once against Docker and once against Podman, each engine in a Linux VM with the host's home directory shared into it, the way Docker Desktop and Podman Desktop work on a Mac. That runner is a clean, persistent Mac with no container engine of its own, so each job installs one for itself in user space (Lima's VM plus Colima and the Docker CLI, or Lima's Podman template plus the podman remote client; pinned and checksum-verified, no sudo), logs it into Docker Hub with the repository's service-account secrets so the per-job image pulls count against the account rather than the machine's anonymous per-IP limit, runs the suite, then removes everything it left behind (its temp dirs, and the engine with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. +- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the whole e2e suite on a self-hosted macOS arm64 runner - the platform most developers use the runtime from - once against Docker and twice against Podman, rootless and rootful, each engine in a Linux VM with the host's home directory shared into it, the way Docker Desktop and Podman's machine work on a Mac. That runner is a persistent Mac with Podman installed from podman.io's package (the install a developer has) and no Docker, so each job makes its own VM: a `podman machine` of its own, created and removed per job, or for Docker the open-source stack brought along in user space (Lima's VM plus Colima and the Docker CLI; pinned and checksum-verified, no sudo). It logs the engine into Docker Hub with the repository's service-account secrets so the per-job image pulls count against the account rather than the machine's anonymous per-IP limit, runs the suite, then removes everything it left behind (its temp dirs, and the VM with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. # Mandatory end-to-end tests From 6952444da41bc60fda1835d06571daddf9e1e3cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 11:26:00 +0000 Subject: [PATCH 15/21] test(e2e): start sample runs with 1 GB, 8 GB on the macOS runner, instead of the samples' 128 MB The samples' own `defaultMemoryMbytes` (128 MB per page) buys a thirty-second of a core under the platform's memory-to-CPU rule, which the runtime enforces faithfully - and a run that small spent 40 s starting Node and 33 s fetching one page on the macOS runner, 85 s per run, eight runs in the dev-folder file alone. The suite now asks for `RUN_MEMORY_MBYTES` on every run it starts (`apify call --memory`, or `memory` in the start params of API-started runs): 1 GB by default, `ACTOR_RUNTIME_E2E_RUN_MEMORY_MBYTES` to raise it, 8 GB on the macOS jobs. The browser-bound files keep their 4 GB floor. The macOS VM gets all but two cores and all but 8 GiB of the machine, rather than half, so those runs have the CPU to use. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-container-engine.sh | 10 ++-- .github/workflows/ci.yml | 7 ++- requirements/test.md | 1 + test/e2e/actor-dev-loop.test.ts | 31 +++++++---- test/e2e/all-modes.test.ts | 5 +- test/e2e/debug-mode.test.ts | 6 +- test/e2e/dev-folder-bind-mount.test.ts | 68 +++++++++++++++-------- test/e2e/helpers/apify-cli.ts | 19 +++++++ test/e2e/helpers/browser-view-suite.ts | 7 ++- test/e2e/monorepo-actor.test.ts | 19 +++++-- test/e2e/nonstandard-actor.test.ts | 23 +++++++- test/e2e/pay-per-event.test.ts | 13 ++++- test/e2e/standby.test.ts | 18 ++++-- test/e2e/web-server.test.ts | 20 +++++-- 14 files changed, 181 insertions(+), 66 deletions(-) diff --git a/.github/scripts/macos-container-engine.sh b/.github/scripts/macos-container-engine.sh index 2c435dff..12ef7ecd 100755 --- a/.github/scripts/macos-container-engine.sh +++ b/.github/scripts/macos-container-engine.sh @@ -130,17 +130,19 @@ sha_for() { awk -v name="$2" '{ f = $2; sub(/^\*/, "", f); if (f == name) { print $1; exit } }' "$1" } -# Half the machine, with a floor: the runtime image build (tsc, pip), Actor builds and emulated -# browsers run in here, and the engines' own defaults (2 CPUs, 2-4 GiB) are tight for that. +# Most of the machine, with floors: all but 2 cores and all but 8 GiB. The runtime image build (tsc, +# pip), Actor builds, emulated browsers and the Actor runs - which the workflow gives 8 GB each, so two +# cores' worth of CPU share - all run in here; the host keeps what macOS and the runner need. The +# engines' own defaults (2 CPUs, 2-4 GiB) are far too tight for that. vm_cpus=2 vm_memory=4 vm_size() { local ncpu memsize ncpu=$(sysctl -n hw.ncpu) memsize=$(sysctl -n hw.memsize) - vm_cpus=$((ncpu / 2)) + vm_cpus=$((ncpu - 2)) [ "$vm_cpus" -ge 2 ] || vm_cpus=2 - vm_memory=$((memsize / 1024 / 1024 / 1024 / 2)) + vm_memory=$((memsize / 1024 / 1024 / 1024 - 8)) [ "$vm_memory" -ge 4 ] || vm_memory=4 echo "VM: $vm_cpus CPUs, ${vm_memory} GiB memory (host: $ncpu CPUs, $((memsize / 1024 / 1024 / 1024)) GiB)" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6893e3f9..7f629385 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,8 +232,13 @@ jobs: path: ${{ runner.temp }}/apify-cli-package - name: Install the apify-cli package built from its ${{ env.APIFY_CLI_REF }} branch run: .github/scripts/install-apify-cli-package.sh "$RUNNER_TEMP/apify-cli-package/apify-cli.tgz" - # Every e2e file, one after another, on this engine. + # Every e2e file, one after another, on this engine. The runs get 8 GB each - two cores + # under the platform's memory-to-CPU rule - rather than the suite's 1 GB default: this VM + # has the memory and cores for it, and a run's CPU share is what its duration hangs on + # (`helpers/apify-cli.ts`'s `RUN_MEMORY_MBYTES`). - run: pnpm run test:e2e + env: + ACTOR_RUNTIME_E2E_RUN_MEMORY_MBYTES: 8192 - name: Dump runtime container logs on failure if: failure() run: | diff --git a/requirements/test.md b/requirements/test.md index 61e6c7ce..7c2211c1 100644 --- a/requirements/test.md +++ b/requirements/test.md @@ -6,6 +6,7 @@ - CI (GitHub Actions) runs on every pull request and on pushes to the main branches: build, lint, format check, and all test layers, with the mandatory CLI-only e2e suite below executing against a real Docker daemon. A missing daemon fails the CI job - the e2e suite never silently skips. - CI runs each e2e file as its own job, in parallel; locally the files run one after another (each starts a runtime container on the fixed ports). +- The suite starts its runs with 1 GB of memory unless `ACTOR_RUNTIME_E2E_RUN_MEMORY_MBYTES` asks for more (the macOS jobs use 8 GB): the samples' own 128 MB-per-page default buys only a thirty-second of a core under the platform's memory-to-CPU rule, which the runtime enforces faithfully, and that made every sample run take over a minute. Only the runs whose memory is what the test checks keep the sample's default. - CI drives every e2e job with `apify-cli` built from the CLI's `actor-runtime` branch - the unreleased CLI the runtime is developed against - rather than the one published on npm: one job packs that branch the way `npm publish` would, and each e2e job installs the package and points the suite at it (`ACTOR_RUNTIME_E2E_APIFY_CLI`). A change on either side that breaks the other fails CI here, before either is released. Locally the suite defaults to the published CLI. - Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the whole e2e suite on a self-hosted macOS arm64 runner - the platform most developers use the runtime from - once against Docker and twice against Podman, rootless and rootful, each engine in a Linux VM with the host's home directory shared into it, the way Docker Desktop and Podman's machine work on a Mac. That runner is a persistent Mac with Podman installed from podman.io's package (the install a developer has) and no Docker, so each job makes its own VM: a `podman machine` of its own, created and removed per job, or for Docker the open-source stack brought along in user space (Lima's VM plus Colima and the Docker CLI; pinned and checksum-verified, no sudo). It logs the engine into Docker Hub with the repository's service-account secrets so the per-job image pulls count against the account rather than the machine's anonymous per-IP limit, runs the suite, then removes everything it left behind (its temp dirs, and the VM with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. diff --git a/test/e2e/actor-dev-loop.test.ts b/test/e2e/actor-dev-loop.test.ts index 50693235..a06ebb8a 100644 --- a/test/e2e/actor-dev-loop.test.ts +++ b/test/e2e/actor-dev-loop.test.ts @@ -29,6 +29,7 @@ import { type CallResult, type DatasetInfoResult, type PushResult, + RUN_MEMORY_ARGS, } from './helpers/apify-cli.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -85,10 +86,13 @@ describe('full Actor dev loop via apify-cli (requires Docker)', () => { expect(push.build.status).toBe('SUCCEEDED'); const itemCountFor = async (maxPages: number): Promise => { - const callOutput = apify(['call', '--input', JSON.stringify({ maxPages }), '--json'], { - cwd: actorDir, - env, - }); + const callOutput = apify( + ['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages }), '--json'], + { + cwd: actorDir, + env, + }, + ); const call = JSON.parse(callOutput) as CallResult; expect(call.run.status).toBe('SUCCEEDED'); @@ -110,7 +114,7 @@ describe('full Actor dev loop via apify-cli (requires Docker)', () => { it('Python sample actor fills the extra datasets its .actor/actor.json declares', () => { const env = apifyEnv(isolatedApifyHome); const actorDir = join(REPO_ROOT, 'samples', 'actor_py'); - const callOutput = apify(['call', '--input', JSON.stringify({ maxPages: 2 }), '--json'], { + const callOutput = apify(['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 2 }), '--json'], { cwd: actorDir, env, }); @@ -149,7 +153,7 @@ describe('full Actor dev loop via apify-cli (requires Docker)', () => { it('the run log contains the crawler per-page lines', () => { const env = apifyEnv(isolatedApifyHome); - const callOutput = apify(['call', '--input', JSON.stringify({ maxPages: 1 }), '--json'], { + const callOutput = apify(['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 1 }), '--json'], { cwd: join(REPO_ROOT, 'samples', 'actor_ts'), env, }); @@ -163,7 +167,7 @@ describe('full Actor dev loop via apify-cli (requires Docker)', () => { const env = apifyEnv(isolatedApifyHome); const actorDir = join(REPO_ROOT, 'samples', 'actor_ts'); - const callOutput = apify(['call', '--json'], { cwd: actorDir, env }); + const callOutput = apify(['call', ...RUN_MEMORY_ARGS, '--json'], { cwd: actorDir, env }); const call = JSON.parse(callOutput) as CallResult; expect(call.run.status).toBe('SUCCEEDED'); @@ -191,16 +195,19 @@ describe('full Actor dev loop via apify-cli (requires Docker)', () => { // `maxPages` has `minimum: 1` - the run is refused before any container starts, and the CLI // surfaces the runtime's validation message. - const rejected = apifyExpectingFailure(['call', '--input', JSON.stringify({ maxPages: 0 }), '--json'], { - cwd: actorDir, - env, - }); + const rejected = apifyExpectingFailure( + ['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 0 }), '--json'], + { + cwd: actorDir, + env, + }, + ); expect(rejected).toMatch(/maxPages/); }); it('apify api reads back the run and its default dataset (requirements/cli.md: `apify api`)', () => { const env = apifyEnv(isolatedApifyHome); - const callOutput = apify(['call', '--input', JSON.stringify({ maxPages: 1 }), '--json'], { + const callOutput = apify(['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 1 }), '--json'], { cwd: join(REPO_ROOT, 'samples', 'actor_ts'), env, }); diff --git a/test/e2e/all-modes.test.ts b/test/e2e/all-modes.test.ts index 4f1ed1bc..d18f5fb0 100644 --- a/test/e2e/all-modes.test.ts +++ b/test/e2e/all-modes.test.ts @@ -34,6 +34,7 @@ import { type ApiEnvelope, type DatasetInfoResult, type PushResult, + RUN_MEMORY_MBYTES, } from './helpers/apify-cli.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -45,7 +46,7 @@ const IMAGE_TAG = 'actor-runtime:e2e-all-modes'; const EXPECTED_IMAGE_WORKING_DIR = '/home/myuser'; const DEBUG_PORT = 9229; /** The default 1024 MB grants a quarter core (`resources.ts`), too little for a headful browser. */ -const RUN_MEMORY_MBYTES = 4096; +const BROWSER_RUN_MEMORY_MBYTES = Math.max(4096, RUN_MEMORY_MBYTES); const MAX_REQUESTS_PER_CRAWL = 4; /** Edited in the dev folder after the push, so printing it proves the mount supplied the code. */ @@ -60,7 +61,7 @@ interface RunApi { function startRun(actorId: string, input: unknown, env: NodeJS.ProcessEnv): RunApi { // Debug mode does not extend the run timeout for the time spent attaching. - const params = JSON.stringify({ memory: RUN_MEMORY_MBYTES, timeout: 900 }); + const params = JSON.stringify({ memory: BROWSER_RUN_MEMORY_MBYTES, timeout: 900 }); const output = apify( ['api', 'POST', `actors/${actorId}/runs`, '--params', params, '--body', JSON.stringify(input)], { cwd: REPO_ROOT, env }, diff --git a/test/e2e/debug-mode.test.ts b/test/e2e/debug-mode.test.ts index f9e54cde..d420e90e 100644 --- a/test/e2e/debug-mode.test.ts +++ b/test/e2e/debug-mode.test.ts @@ -38,6 +38,7 @@ import { removeIsolatedApifyHome, type ApiEnvelope, type PushResult, + RUN_MEMORY_PARAMS, } from './helpers/apify-cli.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -52,7 +53,10 @@ interface RunApi { } function startRun(actorId: string, env: NodeJS.ProcessEnv): RunApi { - const output = apify(['api', 'POST', `actors/${actorId}/runs`, '--body', '{}'], { cwd: REPO_ROOT, env }); + const output = apify( + ['api', 'POST', `actors/${actorId}/runs`, '--params', JSON.stringify(RUN_MEMORY_PARAMS), '--body', '{}'], + { cwd: REPO_ROOT, env }, + ); return (JSON.parse(output) as ApiEnvelope).data; } diff --git a/test/e2e/dev-folder-bind-mount.test.ts b/test/e2e/dev-folder-bind-mount.test.ts index ef03983d..9509796b 100644 --- a/test/e2e/dev-folder-bind-mount.test.ts +++ b/test/e2e/dev-folder-bind-mount.test.ts @@ -40,6 +40,8 @@ import { type ApiEnvelope, type CallResult, type PushResult, + RUN_MEMORY_ARGS, + RUN_MEMORY_PARAMS, } from './helpers/apify-cli.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -150,10 +152,13 @@ describe('local dev-folder bind mount: edit-compile-call loop with no rebuild (r // Run #1: no edit, no recompile since the push above - the host folder's `dist/` still matches // what the image itself was just built from. - const firstCallOutput = apify(['call', '--input', JSON.stringify({ maxPages: 1 }), '--json'], { - cwd: actorDir, - env, - }); + const firstCallOutput = apify( + ['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 1 }), '--json'], + { + cwd: actorDir, + env, + }, + ); const firstCall = JSON.parse(firstCallOutput) as CallResult; expect(firstCall.run.status).toBe('SUCCEEDED'); const firstLog = apifyAllOutput(['runs', 'log', firstCall.run.id], { cwd: REPO_ROOT, env }); @@ -167,10 +172,13 @@ describe('local dev-folder bind mount: edit-compile-call loop with no rebuild (r // Run #2: same registered Actor, same image throughout (asserted below via buildId) - only the // host folder's own compiled output changed. - const secondCallOutput = apify(['call', '--input', JSON.stringify({ maxPages: 1 }), '--json'], { - cwd: actorDir, - env, - }); + const secondCallOutput = apify( + ['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 1 }), '--json'], + { + cwd: actorDir, + env, + }, + ); const secondCall = JSON.parse(secondCallOutput) as CallResult; expect(secondCall.run.status).toBe('SUCCEEDED'); const secondLog = apifyAllOutput(['runs', 'log', secondCall.run.id], { cwd: REPO_ROOT, env }); @@ -215,7 +223,7 @@ describe('local dev-folder bind mount: edit-compile-call loop with no rebuild (r 'POST', `actors/${actorId}/runs`, '--params', - JSON.stringify({ devFolder: 'false', waitForFinish: 180 }), + JSON.stringify({ ...RUN_MEMORY_PARAMS, devFolder: 'false', waitForFinish: 180 }), '--body', JSON.stringify({ maxPages: 1 }), ], @@ -231,7 +239,10 @@ describe('local dev-folder bind mount: edit-compile-call loop with no rebuild (r // The registration survived. const call = JSON.parse( - apify(['call', '--input', JSON.stringify({ maxPages: 1 }), '--json'], { cwd: actorDir, env }), + apify(['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 1 }), '--json'], { + cwd: actorDir, + env, + }), ) as CallResult; expect(call.run.status).toBe('SUCCEEDED'); const callLog = apifyAllOutput(['runs', 'log', call.run.id], { cwd: REPO_ROOT, env }); @@ -284,10 +295,13 @@ describe('local dev-folder bind mount: edit-compile-call loop with no rebuild (r const registered = registerDevFolder(actorId, actorDir, env); expect(registered.data.localDevFolder).toBe(actorDir); - const callOutput = apify(['call', '--input', JSON.stringify({ maxPages: 1 }), '--json'], { - cwd: actorDir, - env, - }); + const callOutput = apify( + ['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 1 }), '--json'], + { + cwd: actorDir, + env, + }, + ); const call = JSON.parse(callOutput) as CallResult; // Succeeds despite `actorDir` having no `node_modules` of its own (removed above) - the crawl // cannot run at all without `apify`/`@crawlee/cheerio` resolving, and the only place they could @@ -336,10 +350,13 @@ describe('local dev-folder bind mount: edit-compile-call loop with no rebuild (r const cleared = JSON.parse(clearOutput) as DevFolderApiResult; expect(cleared.data.localDevFolder).toBeNull(); - const callOutput = apify(['call', '--input', JSON.stringify({ maxPages: 1 }), '--json'], { - cwd: actorDir, - env, - }); + const callOutput = apify( + ['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 1 }), '--json'], + { + cwd: actorDir, + env, + }, + ); const call = JSON.parse(callOutput) as CallResult; expect(call.run.status).toBe('SUCCEEDED'); @@ -391,7 +408,9 @@ describe('local dev-folder bind mount: edit-compile-call loop with no rebuild (r expect(push.build.status).toBe('SUCCEEDED'); registerDevFolder(push.actor.id, devFolder, env); - const call = JSON.parse(apify(['call', '--json'], { cwd: entryActorDir, env })) as CallResult; + const call = JSON.parse( + apify(['call', ...RUN_MEMORY_ARGS, '--json'], { cwd: entryActorDir, env }), + ) as CallResult; expect(call.run.status).toBe('SUCCEEDED'); // The stored log, not `apify call`'s streamed copy: for an Actor that exits within milliseconds // the CLI's stream can close before its last lines are flushed, on any engine. @@ -438,10 +457,13 @@ describe('local dev-folder bind mount: edit-compile-call loop with no rebuild (r const before = countDanglingVolumes(); for (let i = 0; i < 3; i++) { - const callOutput = apify(['call', '--input', JSON.stringify({ maxPages: 1 }), '--json'], { - cwd: actorDir, - env, - }); + const callOutput = apify( + ['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 1 }), '--json'], + { + cwd: actorDir, + env, + }, + ); const call = JSON.parse(callOutput) as CallResult; expect(call.run.status).toBe('SUCCEEDED'); } diff --git a/test/e2e/helpers/apify-cli.ts b/test/e2e/helpers/apify-cli.ts index 8de1326c..b2bb95d2 100644 --- a/test/e2e/helpers/apify-cli.ts +++ b/test/e2e/helpers/apify-cli.ts @@ -12,6 +12,25 @@ import { join } from 'node:path'; */ const LOCAL_APIFY_CLI_ENV_VAR = 'ACTOR_RUNTIME_E2E_APIFY_CLI'; +/** + * Memory the suite asks for on every run it starts, where the run's memory is not itself under test: + * the samples' own defaults (`defaultMemoryMbytes`, 128 MB per page for `actor_ts` / `actor_py`) buy + * the platform's matching CPU share - a thirty-second of a core at 128 MB (`resources.ts`) - and a + * run that small spends over a minute starting Node and fetching one page. The CI jobs set this per + * machine (`ci.yml`: more on the macOS runner, whose VM has the memory and cores to spare); 1 GB, a + * quarter core, is the floor. + */ +const RUN_MEMORY_ENV_VAR = 'ACTOR_RUNTIME_E2E_RUN_MEMORY_MBYTES'; +const DEFAULT_RUN_MEMORY_MBYTES = 1024; +export const RUN_MEMORY_MBYTES = Math.max( + DEFAULT_RUN_MEMORY_MBYTES, + Number(process.env[RUN_MEMORY_ENV_VAR]) || DEFAULT_RUN_MEMORY_MBYTES, +); +/** For `apify call`: spread right after `'call'`. */ +export const RUN_MEMORY_ARGS = ['--memory', String(RUN_MEMORY_MBYTES)]; +/** For `apify api POST actors/:id/runs --params`: merge into the query parameters. */ +export const RUN_MEMORY_PARAMS = { memory: RUN_MEMORY_MBYTES }; + /** * By default the stock, unmodified `apify-cli` from npm (v1.8.0 or newer, per `cli.md`) via `npx`, so the * e2e suite does not require a global install. No fork/patch of the CLI - exactly `cli.md`'s contract. diff --git a/test/e2e/helpers/browser-view-suite.ts b/test/e2e/helpers/browser-view-suite.ts index 141433c4..49d7cb7c 100644 --- a/test/e2e/helpers/browser-view-suite.ts +++ b/test/e2e/helpers/browser-view-suite.ts @@ -32,6 +32,7 @@ import { type CallResult, type DatasetInfoResult, type PushResult, + RUN_MEMORY_MBYTES, } from './apify-cli.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -58,10 +59,10 @@ interface RunApi { } /** The default 1024 MB grants a quarter core (`resources.ts`), too little for a headful browser. */ -const RUN_MEMORY_MBYTES = 4096; +const BROWSER_RUN_MEMORY_MBYTES = Math.max(4096, RUN_MEMORY_MBYTES); function startRun(actorId: string, input: unknown, env: NodeJS.ProcessEnv): RunApi { - const params = JSON.stringify({ memory: RUN_MEMORY_MBYTES, timeout: 600 }); + const params = JSON.stringify({ memory: BROWSER_RUN_MEMORY_MBYTES, timeout: 600 }); const output = apify( ['api', 'POST', `actors/${actorId}/runs`, '--params', params, '--body', JSON.stringify(input)], { @@ -226,7 +227,7 @@ export function describeBrowserViewSuite(sample: BrowserViewSample): void { '--input', JSON.stringify(sample.input(2)), '--memory', - String(RUN_MEMORY_MBYTES), + String(BROWSER_RUN_MEMORY_MBYTES), '--json', ], { diff --git a/test/e2e/monorepo-actor.test.ts b/test/e2e/monorepo-actor.test.ts index 1c8b097e..0c9a4ea7 100644 --- a/test/e2e/monorepo-actor.test.ts +++ b/test/e2e/monorepo-actor.test.ts @@ -31,6 +31,7 @@ import { removeIsolatedApifyHome, type CallResult, type PushResult, + RUN_MEMORY_ARGS, } from './helpers/apify-cli.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -98,7 +99,10 @@ describe.skipIf(!process.env.ACTOR_RUNTIME_E2E_APIFY_CLI)('monorepo Actors via a expect(buildLog).toContain('RUN ./shared/check-context.sh'); const call = JSON.parse( - apify(['call', '--input', JSON.stringify({ count: 3 }), '--json'], { cwd: actorDir, env }), + apify(['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ count: 3 }), '--json'], { + cwd: actorDir, + env, + }), ) as CallResult; expect(call.run.status).toBe('SUCCEEDED'); @@ -120,7 +124,7 @@ describe.skipIf(!process.env.ACTOR_RUNTIME_E2E_APIFY_CLI)('monorepo Actors via a const env = apifyEnv(isolatedApifyHome); const actorDir = join(MONOREPO_ROOT, 'actors', 'greeter'); - const call = JSON.parse(apify(['call', '--json'], { cwd: actorDir, env })) as CallResult; + const call = JSON.parse(apify(['call', ...RUN_MEMORY_ARGS, '--json'], { cwd: actorDir, env })) as CallResult; expect(call.run.status).toBe('SUCCEEDED'); const storedInput = apify(['key-value-stores', 'get-value', call.storage.defaultKeyValueStoreId, 'INPUT'], { cwd: actorDir, @@ -128,10 +132,13 @@ describe.skipIf(!process.env.ACTOR_RUNTIME_E2E_APIFY_CLI)('monorepo Actors via a }); expect(JSON.parse(storedInput) as Record).toEqual({ count: 2 }); - const rejected = apifyExpectingFailure(['call', '--input', JSON.stringify({ count: 0 }), '--json'], { - cwd: actorDir, - env, - }); + const rejected = apifyExpectingFailure( + ['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ count: 0 }), '--json'], + { + cwd: actorDir, + env, + }, + ); expect(rejected).toMatch(/count/); }); diff --git a/test/e2e/nonstandard-actor.test.ts b/test/e2e/nonstandard-actor.test.ts index 6e4be16d..6956e58f 100644 --- a/test/e2e/nonstandard-actor.test.ts +++ b/test/e2e/nonstandard-actor.test.ts @@ -34,6 +34,8 @@ import { type CallResult, type DatasetInfoResult, type PushResult, + RUN_MEMORY_ARGS, + RUN_MEMORY_PARAMS, } from './helpers/apify-cli.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -69,7 +71,9 @@ function buildLog(buildId: string, env: NodeJS.ProcessEnv): string { } function callWith(input: object, env: NodeJS.ProcessEnv, cwd = SAMPLE_ACTOR_DIR): CallResult { - return JSON.parse(apify(['call', '--input', JSON.stringify(input), '--json'], { cwd, env })) as CallResult; + return JSON.parse( + apify(['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify(input), '--json'], { cwd, env }), + ) as CallResult; } function registerDevFolder(id: string, path: string, env: NodeJS.ProcessEnv): { localDevFolder: string | null } { @@ -295,7 +299,9 @@ describe('non-standard Actors: unusual base image, custom entry point, unusual w expect(registerDevFolder(push.actor.id, devFolder, env).localDevFolder).toBe(devFolder); - const call = JSON.parse(apify(['call', '--json'], { cwd: noWorkdirActorDir, env })) as CallResult; + const call = JSON.parse( + apify(['call', ...RUN_MEMORY_ARGS, '--json'], { cwd: noWorkdirActorDir, env }), + ) as CallResult; // The run starts exactly as if the feature did not exist... expect(call.run.status).toBe('SUCCEEDED'); const log = storedLog(call.run.id, env); @@ -333,7 +339,18 @@ describe('non-standard Actors: unusual base image, custom entry point, unusual w // starts a run under the hood, returning immediately. const run = ( JSON.parse( - apify(['api', 'POST', `actors/${actorId}/runs`, '--body', '{}'], { cwd: REPO_ROOT, env }), + apify( + [ + 'api', + 'POST', + `actors/${actorId}/runs`, + '--params', + JSON.stringify(RUN_MEMORY_PARAMS), + '--body', + '{}', + ], + { cwd: REPO_ROOT, env }, + ), ) as ApiEnvelope<{ id: string; status: string; diff --git a/test/e2e/pay-per-event.test.ts b/test/e2e/pay-per-event.test.ts index b3b9ab64..f9bb2cea 100644 --- a/test/e2e/pay-per-event.test.ts +++ b/test/e2e/pay-per-event.test.ts @@ -26,6 +26,7 @@ import { type ApiEnvelope, type CallResult, type PushResult, + RUN_MEMORY_ARGS, } from './helpers/apify-cli.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -124,7 +125,10 @@ describe('pay-per-event pricing and the run cost estimate via apify-cli (require // A free run first: no pricing fields at all, but the compute-unit estimate is there. const freeCall = JSON.parse( - apify(['call', '--input', JSON.stringify({ maxPages: 2 }), '--json'], { cwd: SAMPLE_ACTOR_DIR, env }), + apify(['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 2 }), '--json'], { + cwd: SAMPLE_ACTOR_DIR, + env, + }), ) as CallResult; expect(freeCall.run.status).toBe('SUCCEEDED'); const freeRun = getRun(freeCall.run.id, env); @@ -150,12 +154,15 @@ describe('pay-per-event pricing and the run cost estimate via apify-cli (require expect(priced.data.pricingInfos).toHaveLength(1); const call = JSON.parse( - apify(['call', '--input', JSON.stringify({ maxPages: 3 }), '--json'], { cwd: SAMPLE_ACTOR_DIR, env }), + apify(['call', ...RUN_MEMORY_ARGS, '--input', JSON.stringify({ maxPages: 3 }), '--json'], { + cwd: SAMPLE_ACTOR_DIR, + env, + }), ) as CallResult; expect(call.run.status).toBe('SUCCEEDED'); const run = getRun(call.run.id, env); expect(run.pricingInfo?.pricingModel).toBe('PAY_PER_EVENT'); - // 3 pages -> 512 MB from the sample's defaultMemoryMbytes -> the start event once; the SDK charged the page and crawl events over the endpoint. + // The start event once; the SDK charged the page and crawl events over the endpoint. expect(run.chargedEventCounts).toEqual({ [PAGE_EVENT]: 3, [FINISHED_EVENT]: 1, diff --git a/test/e2e/standby.test.ts b/test/e2e/standby.test.ts index 0bf9b50d..05c23c93 100644 --- a/test/e2e/standby.test.ts +++ b/test/e2e/standby.test.ts @@ -31,6 +31,7 @@ import { type CallResult, type DatasetInfoResult, type PushResult, + RUN_MEMORY_ARGS, } from './helpers/apify-cli.js'; import { waitFor } from './helpers/wait.js'; @@ -270,10 +271,19 @@ describe('Actor Standby via apify-cli (requires Docker)', () => { // An ordinary run of the sample is the client: it reads the standby Actor's standbyUrl through the // API from its container and calls `/api/greeting` there. const call = JSON.parse( - apify(['call', '--input', JSON.stringify({ standbyActor: actorId, name: 'Actor' }), '--json'], { - cwd: actorDir, - env, - }), + apify( + [ + 'call', + ...RUN_MEMORY_ARGS, + '--input', + JSON.stringify({ standbyActor: actorId, name: 'Actor' }), + '--json', + ], + { + cwd: actorDir, + env, + }, + ), ) as CallResult; expect(call.run.status).toBe('SUCCEEDED'); diff --git a/test/e2e/web-server.test.ts b/test/e2e/web-server.test.ts index 5be16a81..6b2d9b85 100644 --- a/test/e2e/web-server.test.ts +++ b/test/e2e/web-server.test.ts @@ -25,6 +25,7 @@ import { removeIsolatedApifyHome, type ApiEnvelope, type PushResult, + RUN_MEMORY_PARAMS, } from './helpers/apify-cli.js'; import { fetchConsole } from './helpers/console-view.js'; import { waitFor } from './helpers/wait.js'; @@ -96,10 +97,21 @@ describe('Run web server via apify-cli (requires Docker)', () => { // Started via `apify api`, not `apify call`, so the page can be read while the run goes. const started = ( JSON.parse( - apify(['api', 'POST', `actors/${push.actor.id}/runs`, '--body', JSON.stringify({ maxPages: 5 })], { - cwd: REPO_ROOT, - env, - }), + apify( + [ + 'api', + 'POST', + `actors/${push.actor.id}/runs`, + '--params', + JSON.stringify(RUN_MEMORY_PARAMS), + '--body', + JSON.stringify({ maxPages: 5 }), + ], + { + cwd: REPO_ROOT, + env, + }, + ), ) as ApiEnvelope ).data; const runId = started.id; From a8d089049585fb49f95c123c6fceafe76dbcbe53 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 11:33:44 +0000 Subject: [PATCH 16/21] ci(macos): run only the files that prove the hard features on the self-hosted runner The hosted legs already run every e2e file on every engine. The macOS leg is for what differs on a Mac - the engine in a VM, folders shared over virtiofs, amd64 images under Rosetta, `*.localhost` names and published ports crossing the VM boundary - so it now runs the smallest set of files that drives every hard feature through all of that: `all-modes` (one run with debug mode, the live dev folder and browser view on, a local edit running without a rebuild, the display streamed live), `web-server` (the run's live view at its container URL) and `standby` (Standby Actors, one reached from another Actor's container). Everything else is engine-independent and stays on the hosted legs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++-------- requirements/test.md | 2 +- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f629385..81340584 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,7 +169,7 @@ jobs: done e2e-macos-arm64: - name: e2e all files (apify-cli + ${{ matrix.engine }} on macOS arm64, self-hosted) + name: e2e advanced modes (apify-cli + ${{ matrix.engine }} on macOS arm64, self-hosted) # The leg on the platform most developers run the runtime on: an Apple Silicon Mac, with the # engine in a Linux VM, the host's `$HOME` shared into it, and the socket the runtime container # gets mounted being the VM's, not a kernel socket of the host. Each job starts its own VM and @@ -180,17 +180,31 @@ jobs: # modes `apify runtime start` handles are covered. It is a persistent self-hosted machine, so # unlike every hosted leg above it is also cleaned before and after. # - # One job per engine running every e2e file in turn (`test:e2e` serializes them: fixed host - # ports), rather than one job per file as above: the engine setup is per job, and there is - # one runner, so jobs queue rather than parallelize anyway. The browser-view files pull ~2 GB - # base images and run Chrome under amd64 emulation, hence the wide budget. + # One job per engine running a few e2e files in turn (fixed host ports, so never in parallel), + # rather than one job per file as above: the engine setup is per job, and there is one runner, + # so jobs queue rather than parallelize anyway. Not every file: the hosted legs already prove + # the runtime's behaviour on every engine, and this leg is for what is different on a Mac - the + # engine in a VM, the host's folders reaching it over virtiofs, amd64 images under Rosetta, + # `*.localhost` names and published ports crossing the VM boundary. The files below are the + # smallest set that drives every hard feature through all of that (`MACOS_E2E_FILES`). The + # Playwright images are ~2 GB pulls and run Chrome under emulation, hence the budget. # # Self-hosted, so never for a fork's pull request: `pull_request` runs the head branch's code, # and the runner is a long-lived Apify machine. Pushes to master and same-repo PRs only. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository needs: apify-cli runs-on: [self-hosted, macOS, ARM64] - timeout-minutes: 120 + timeout-minutes: 60 + env: + # - all-modes: one run with debug mode, the live dev folder and browser view all on - a local + # edit runs without a rebuild (the dev folder over virtiofs), the browser's display streams + # live (amd64 Playwright image under Rosetta), the debugger port is published + # - web-server: the run's own web server is served live at its `*.localhost` container URL + # - standby: Standby Actors (Node, Python, one owning `/`) served at their standby URLs, and + # reached from another Actor's container + # Everything else (dev loop, debug mode alone, dev-folder edge cases, pricing, non-standard + # and monorepo Actors) is engine-independent and covered by the hosted legs. + MACOS_E2E_FILES: test/e2e/all-modes.test.ts test/e2e/web-server.test.ts test/e2e/standby.test.ts strategy: fail-fast: false matrix: @@ -232,11 +246,11 @@ jobs: path: ${{ runner.temp }}/apify-cli-package - name: Install the apify-cli package built from its ${{ env.APIFY_CLI_REF }} branch run: .github/scripts/install-apify-cli-package.sh "$RUNNER_TEMP/apify-cli-package/apify-cli.tgz" - # Every e2e file, one after another, on this engine. The runs get 8 GB each - two cores + # The files above, one after another, on this engine. The runs get 8 GB each - two cores # under the platform's memory-to-CPU rule - rather than the suite's 1 GB default: this VM # has the memory and cores for it, and a run's CPU share is what its duration hangs on # (`helpers/apify-cli.ts`'s `RUN_MEMORY_MBYTES`). - - run: pnpm run test:e2e + - run: pnpm exec vitest run $MACOS_E2E_FILES --no-file-parallelism env: ACTOR_RUNTIME_E2E_RUN_MEMORY_MBYTES: 8192 - name: Dump runtime container logs on failure diff --git a/requirements/test.md b/requirements/test.md index 7c2211c1..4c647445 100644 --- a/requirements/test.md +++ b/requirements/test.md @@ -8,7 +8,7 @@ - CI runs each e2e file as its own job, in parallel; locally the files run one after another (each starts a runtime container on the fixed ports). - The suite starts its runs with 1 GB of memory unless `ACTOR_RUNTIME_E2E_RUN_MEMORY_MBYTES` asks for more (the macOS jobs use 8 GB): the samples' own 128 MB-per-page default buys only a thirty-second of a core under the platform's memory-to-CPU rule, which the runtime enforces faithfully, and that made every sample run take over a minute. Only the runs whose memory is what the test checks keep the sample's default. - CI drives every e2e job with `apify-cli` built from the CLI's `actor-runtime` branch - the unreleased CLI the runtime is developed against - rather than the one published on npm: one job packs that branch the way `npm publish` would, and each e2e job installs the package and points the suite at it (`ACTOR_RUNTIME_E2E_APIFY_CLI`). A change on either side that breaks the other fails CI here, before either is released. Locally the suite defaults to the published CLI. -- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the whole e2e suite on a self-hosted macOS arm64 runner - the platform most developers use the runtime from - once against Docker and twice against Podman, rootless and rootful, each engine in a Linux VM with the host's home directory shared into it, the way Docker Desktop and Podman's machine work on a Mac. That runner is a persistent Mac with Podman installed from podman.io's package (the install a developer has) and no Docker, so each job makes its own VM: a `podman machine` of its own, created and removed per job, or for Docker the open-source stack brought along in user space (Lima's VM plus Colima and the Docker CLI; pinned and checksum-verified, no sudo). It logs the engine into Docker Hub with the repository's service-account secrets so the per-job image pulls count against the account rather than the machine's anonymous per-IP limit, runs the suite, then removes everything it left behind (its temp dirs, and the VM with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. +- Beside the hosted Linux runners (Docker and Podman, x86_64 and arm64), CI runs the e2e files that drive the hardest features - one run with debug mode, the live dev folder and browser view all on, the live view of a run's web server, and Standby Actors - on a self-hosted macOS arm64 runner - the platform most developers use the runtime from, and the one where those features cross a VM boundary (the engine's VM, folders shared over virtiofs, amd64 images under Rosetta) - once against Docker and twice against Podman, rootless and rootful, each engine in a Linux VM with the host's home directory shared into it, the way Docker Desktop and Podman's machine work on a Mac. That runner is a persistent Mac with Podman installed from podman.io's package (the install a developer has) and no Docker, so each job makes its own VM: a `podman machine` of its own, created and removed per job, or for Docker the open-source stack brought along in user space (Lima's VM plus Colima and the Docker CLI; pinned and checksum-verified, no sudo). It logs the engine into Docker Hub with the repository's service-account secrets so the per-job image pulls count against the account rather than the machine's anonymous per-IP limit, runs the suite, then removes everything it left behind (its temp dirs, and the VM with every container, image and volume in it), leaving the machine as it found it. The job never runs for pull requests from forks. # Mandatory end-to-end tests From 6e9c3f83c3f28a4d7facd6f61c50c8fb2fe87f8e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 11:35:23 +0000 Subject: [PATCH 17/21] ci(macos): drop `--rosetta` from `podman machine init`, gone in Podman 6 The runner's Podman is 6.1.4, whose `machine init` has no `--rosetta` flag: Rosetta is the `[machine] rosetta` setting of containers.conf, on by default for applehv on Apple silicon. The machine is created without the flag; only when the host has no Rosetta is the setting switched off through `CONTAINERS_CONF_OVERRIDE` for that one command, so the QEMU fallback can take over. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-container-engine.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/scripts/macos-container-engine.sh b/.github/scripts/macos-container-engine.sh index 12ef7ecd..b827c005 100755 --- a/.github/scripts/macos-container-engine.sh +++ b/.github/scripts/macos-container-engine.sh @@ -378,16 +378,23 @@ install_podman() { echo "::group::Create the Podman machine ($mode)" vm_size ensure_rosetta || true + # Rosetta is a `containers.conf` setting (`[machine] rosetta`, on by default for applehv on Apple + # silicon; Podman 6 has no `init` flag for it). Only with no Rosetta on the host is it switched off, + # through an override file for this one command, so the machine starts and the fallback below + # registers QEMU handlers instead. + if [ -z "$rosetta" ]; then + printf '[machine]\nrosetta = false\n' > "$tmp_dir/containers-no-rosetta.conf" + export CONTAINERS_CONF_OVERRIDE=$tmp_dir/containers-no-rosetta.conf + fi # `--memory` is MiB. `--disk-size` is a sparse maximum. Default mounts (`/Users`, `/private`, - # `/var/folders`) cover `$HOME`. `--rosetta` is the applehv provider's; off, the VM has no amd64 - # handler and the fallback below registers QEMU ones. + # `/var/folders`) cover `$HOME`. podman machine init \ --cpus "$vm_cpus" \ --memory "$((vm_memory * 1024))" \ --disk-size 60 \ - --rosetta="$([ -n "$rosetta" ] && echo true || echo false)" \ $([ "$mode" = rootful ] && echo --rootful) \ "$PODMAN_MACHINE" + unset CONTAINERS_CONF_OVERRIDE podman machine start "$PODMAN_MACHINE" podman machine list podman system connection list From 0b3af66b9213266b8b9e434c4e9380ca7fa65bcb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 12:05:28 +0000 Subject: [PATCH 18/21] ci(macos): make Rosetta, not the image's QEMU handler, serve amd64 in the podman machine On the rootless podman machine the Playwright sample's amd64 Chrome ran under `qemu-x86_64-static` and crashed (`QEMU internal SIGSEGV`), although Rosetta is installed and the machine's `rosetta-activation.service` is enabled: the image's own QEMU binfmt handlers were what served x86_64 binaries. The standby and web-server files passed on that machine. After the machine starts, the script now runs, as the VM's root: a report of the activation service, the Rosetta mount and the registered handlers; Rosetta's registration if its share is mounted but no handler exists; and removal of the x86_64 QEMU handler whenever a Rosetta one is there, so Rosetta is the only handler for amd64 binaries. The smoke test reports which emulator an amd64 container actually ran under, and warns when it is QEMU. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013oHHo44jxAVWrVrBvf2DBk --- .github/scripts/macos-container-engine.sh | 65 +++++++++++++++++++---- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/.github/scripts/macos-container-engine.sh b/.github/scripts/macos-container-engine.sh index b827c005..5c8c663a 100755 --- a/.github/scripts/macos-container-engine.sh +++ b/.github/scripts/macos-container-engine.sh @@ -46,9 +46,11 @@ # # amd64 emulation: the Playwright sample Actors' base images have no arm64 build, so the browser-view # files build and run them as amd64 (`build-platform.ts`'s fallback). With Rosetta on the host the VM -# gets it (`--vz-rosetta` / `--rosetta`), which is fast; without it the fallback is QEMU user-mode -# emulation registered in the VM (`tonistiigi/binfmt`), which is slow but works. Installing Rosetta is -# attempted once; it may need admin rights. +# gets it (Colima's `--vz-rosetta`; Podman's machine by its `containers.conf` default), which is fast +# and runs Chrome; without it the fallback is QEMU user-mode emulation in the VM (`tonistiigi/binfmt` +# for Docker, the handlers Podman's image ships), which is slow and cannot run Chrome. Installing +# Rosetta is attempted once; it may need admin rights. In Podman's machine the script also makes sure +# Rosetta, not the image's QEMU handler, is what serves amd64 binaries (`ROSETTA_IN_VM_SCRIPT`). # # Docker Hub: each job starts from an empty engine and pulls every base image again, and Docker Hub's # anonymous pull limit is per IP - a day of runs on one Mac exhausts it ("toomanyrequests"). With @@ -221,7 +223,14 @@ smoke_tests() { # amd64 emulation, for the browser-view files. A warning, not a failure: only those files need it, # and their own assertions will say so if it is missing. if "$cli" run --rm --platform linux/amd64 docker.io/library/busybox uname -m 2>/dev/null | grep -q x86_64; then - echo "amd64 containers run on this engine." + # Which emulator: the interpreter binfmt_misc hands the binary to is mapped into the process. + local emulator + emulator=$("$cli" run --rm --platform linux/amd64 docker.io/library/busybox \ + sh -c 'grep -o -m1 -E "rosetta|qemu-x86_64[a-z_-]*" /proc/self/maps' 2>/dev/null || true) + echo "amd64 containers run on this engine (via ${emulator:-an emulator /proc/self/maps does not name})." + case "$emulator" in + qemu*) warn "amd64 containers run under QEMU, not Rosetta; the browser-view e2e files will fail (Chrome crashes under QEMU)." ;; + esac else warn "amd64 containers do not run on this engine; the browser-view e2e files will fail." fi @@ -358,6 +367,39 @@ find_podman() { echo "Using $(command -v podman): $(podman --version)" } +# vm_root_sh