diff --git a/AGENTS.md b/AGENTS.md index a5e0b2de..092516c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ Adding a cross-boundary feature: **Key commands:** `pnpm run dev`, `pnpm run lint`, `pnpm run typecheck`, `pnpm run test:run`, `pnpm run update`. Reticulum sidecar: `pnpm run check:reticulum-sidecar` (pre-commit stub), `pnpm run reticulum:sidecar:clippy:full`, `pnpm run reticulum:sidecar:test`. -**Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs Docker + act). Host mode — `act:ci:native`, `act:tests:native`, … (no Docker). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing. +**Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs a Docker-compatible engine + act; Podman preferred). Host mode — `act:ci:native`, `act:tests:native`, … (no container engine). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing. > **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF / rsNomad) and warns when a local overlay can be removed. `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). diff --git a/docs/ci-cd.md b/docs/ci-cd.md index f2293268..047fc30c 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -140,12 +140,14 @@ Do not use `npm install`; it will create a `package-lock.json` and may not respe **Optional tooling:** You can run local CI in two ways: -| Mode | Command prefix | Requires | What it does | -| ----------------------- | ----------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------------- | -| **Container** (default) | `pnpm run act:ci`, `act:tests`, … | Docker + [act](https://github.com/nektos/act) | Runs GitHub Actions jobs inside Linux containers (closest to CI) | -| **Host / native** | `pnpm run act:ci:native`, `act:tests:native`, … | Node/pnpm only | Runs the same pnpm/cargo steps directly on your machine (no Docker) | +| Mode | Command prefix | Requires | What it does | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| **Container** (default) | `pnpm run act:ci`, `pnpm run act:tests`, … | Docker-compatible engine + [act](https://github.com/nektos/act) | Runs GitHub Actions jobs inside Linux containers (closest to CI) | +| **Host / native** | `pnpm run act:ci:native`, `pnpm run act:tests:native`, … | Node/pnpm only | Runs the same pnpm/cargo steps directly on your machine (no container engine) | -`pnpm run check:environment` warns if Docker or act is missing but does not block commits. Use **native** scripts when Docker Desktop is unavailable or act cannot reach the daemon. +Container mode runs GitHub Actions jobs inside Linux containers using a Docker-compatible engine (Podman preferred). Host mode runs the same pnpm/cargo steps directly — use this when no container engine is available or act cannot reach the daemon. `pnpm run check:environment` warns if no container engine or act is missing but does not block commits. Use **native** scripts when no Docker-compatible engine is available or act cannot reach the daemon. + +**macOS note:** [Podman Desktop](https://podman.io/) is the preferred Docker-compatible engine for local CI. When Docker compatibility is enabled, Podman exposes a Docker-compatible socket at `/var/run/docker.sock`; pass that path to `act` via `ACT_DOCKER_SOCKET`, or let `act` detect it automatically if Podman created the symlink. If you use Docker Desktop instead, its socket is typically under `~/.docker/run/docker.sock`. Install act (container mode only): @@ -157,9 +159,9 @@ brew install act # https://github.com/nektos/act/releases ``` -On Windows, use Docker Desktop with the WSL2 backend. On Apple Silicon, act uses `--container-architecture linux/amd64` automatically for x86_64 CI parity. +On Windows, Podman Desktop with Docker compatibility is preferred. If you use Docker Desktop instead, use the WSL2 backend. On Apple Silicon, `act` uses `--container-architecture linux/amd64` automatically for x86_64 CI parity. -**Docker Desktop:** `scripts/run-act.mjs` passes `--container-daemon-socket` to act (auto-detects `~/.docker/run/docker.sock` on macOS). If act still cannot connect, set `ACT_DOCKER_SOCKET` to your socket path or use native mode. +**Podman:** `scripts/run-act.mjs` passes `--container-daemon-socket` to `act` (auto-detects `/var/run/docker.sock` on macOS). If `act` still cannot connect, set `ACT_DOCKER_SOCKET` to your socket path or use native mode. If you use Docker Desktop instead, its socket is typically under `~/.docker/run/docker.sock`. ### Package scripts @@ -170,12 +172,12 @@ pnpm run act:pull-images # List targets pnpm run act:list -# PR parity — container (act + Docker) +# PR parity — container (act + Podman/Docker) pnpm run act:ci pnpm run act:tests pnpm run act:pr -# PR parity — host (no Docker) +# PR parity — host (no container engine) pnpm run act:ci:native pnpm run act:tests:native pnpm run act:pr:native diff --git a/docs/contributing.md b/docs/contributing.md index cddc561f..b2e29fd7 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -23,7 +23,7 @@ pnpm run typecheck pnpm run format:check ``` -To approximate **Linux CI** from any host, use container mode (`pnpm run act:ci`) or host mode without Docker (`pnpm run act:ci:native`). See [CI/CD — Running locally with act](ci-cd.md#running-ci-locally-with-act). +To approximate **Linux CI** from any host, use container mode (`pnpm run act:ci`) or host mode without a container engine (`pnpm run act:ci:native`). See [CI/CD — Running locally with act](ci-cd.md#running-ci-locally-with-act). ## Documentation Workflow diff --git a/docs/development-environment.md b/docs/development-environment.md index ec88da65..aa55cb53 100644 --- a/docs/development-environment.md +++ b/docs/development-environment.md @@ -34,7 +34,7 @@ pnpm run dev **Required** checks (must pass): Git, Node.js, pnpm, `node_modules`, and platform-native build tools (Xcode CLT on macOS, `g++`/`make` on Linux, MSVC `cl` on Windows). -**Optional** checks (warnings only): Python/pip, Rust, actionlint, yamllint, Docker, act, and Linux `dialout` group membership. For local CI you can use **container mode** (`pnpm run act:ci` — act + Docker) or **host mode** (`pnpm run act:ci:native` — no Docker). Fix optional items when you need docs builds, pre-commit hooks, Reticulum sidecar work, local CI parity, or USB serial on Linux. +**Optional** checks (warnings only): Python/pip, Rust, actionlint, yamllint, a Docker-compatible container engine (Podman preferred), act, and Linux `dialout` group membership. For local CI you can use **container mode** (`pnpm run act:ci` — act + Podman/Docker) or **host mode** (`pnpm run act:ci:native` — no container engine). Fix optional items when you need docs builds, pre-commit hooks, Reticulum sidecar work, local CI parity, or USB serial on Linux. Use the printed `→` hints and `setup:*` scripts (`setup:build-deps`, `setup:actionlint`, `setup:dialout`) to fix failures. See [Helper scripts (auto-install where possible)](#8-helper-scripts-auto-install-where-possible) below. @@ -410,21 +410,21 @@ flatpak run --command=flatpak-builder-lint org.freedesktop.Sdk \ #### CI (act) -| Script | Description | -| ------------------------ | ------------------------------------------------------- | -| `act:ci` | Run Linux CI workflow via act + Docker (container mode) | -| `act:ci:native` | Run CI checks on the host (no Docker) | -| `act:tests` | Run tests workflow via act + Docker | -| `act:tests:native` | Run `test:coverage` on the host | -| `act:pr` | Run `act:ci` then `act:tests` (container) | -| `act:pr:native` | Run native CI + tests on the host | -| `act:build:linux` | Run `build.yaml` ubuntu leg via act + Docker | -| `act:build:linux:native` | Run `dist:linux` on the host | -| `act:reticulum` | Reticulum sidecar Linux jobs via act | -| `act:reticulum:native` | Reticulum sidecar stub `cargo test` / build on the host | -| `act:flatpak` | Flatpak x86_64 workflow via act (slow; privileged) | -| `act:pull-images` | Pre-pull Docker images for act (container mode) | -| `act:list` | List container and native act targets | +| Script | Description | +| ------------------------ | ------------------------------------------------------------- | +| `act:ci` | Run Linux CI workflow via act + containers (Podman preferred) | +| `act:ci:native` | Run CI checks on the host (no container engine) | +| `act:tests` | Run tests workflow via act + containers | +| `act:tests:native` | Run `test:coverage` on the host | +| `act:pr` | Run `act:ci` then `act:tests` (container) | +| `act:pr:native` | Run native CI + tests on the host | +| `act:build:linux` | Run `build.yaml` ubuntu leg via act + containers | +| `act:build:linux:native` | Run `dist:linux` on the host | +| `act:reticulum` | Reticulum sidecar Linux jobs via act | +| `act:reticulum:native` | Reticulum sidecar stub `cargo test` / build on the host | +| `act:flatpak` | Flatpak x86_64 workflow via act (slow; privileged) | +| `act:pull-images` | Pre-pull Docker images for act (container mode) | +| `act:list` | List container and native act targets | #### Setup / helpers @@ -498,7 +498,7 @@ Not installed by pnpm (install separately when needed): - `actionlint` (recommended for workflow linting; run `pnpm run setup:actionlint` or install system-wide) - `yamllint` (required for YAML linting; install via `pip install yamllint` or `brew install yamllint` on macOS) - **Rust / `cargo`** (optional; Reticulum sidecar — see [Reticulum sidecar](#reticulum-sidecar-optional); prefer [rustup](https://rustup.rs/)). `rust-toolchain.toml` installs clippy/rustfmt/llvm-tools-preview on first build in `reticulum-sidecar/`. Optional: `cargo install cargo-llvm-cov` for `pnpm run reticulum:sidecar:coverage`. -- `docker` and `act` (optional for container CI — `act:*`; host CI uses `act:*:native` without Docker) +- Podman or Docker (Docker-compatible engine) and `act` (optional for container CI — `act:*`; host CI uses `act:*:native` without a container engine) - Python 3 + `venv` + MkDocs Python deps (for docs checks/builds) #### Vitest projects and worker allocation @@ -631,20 +631,20 @@ Run any skipped checks manually as soon as possible. Local CI has two modes: -| Mode | Scripts | Requires | -| ----------------- | ----------------------------------------------- | -------------- | -| **Container** | `pnpm run act:ci`, `act:tests`, … | Docker + act | -| **Host / native** | `pnpm run act:ci:native`, `act:tests:native`, … | Node/pnpm only | +| Mode | Scripts | Requires | +| ----------------- | -------------------------------------------------------- | ------------------------------------------------- | +| **Container** | `pnpm run act:ci`, `pnpm run act:tests`, … | Docker-compatible engine + act (Podman preferred) | +| **Host / native** | `pnpm run act:ci:native`, `pnpm run act:tests:native`, … | Node/pnpm only | -Container mode runs GitHub Actions jobs inside Docker (closest to CI). Host mode runs the same pnpm/cargo steps directly — use this when Docker Desktop is off or act cannot reach the daemon. +Container mode runs GitHub Actions jobs inside containers using a Docker-compatible engine (Podman Desktop preferred). Host mode runs the same pnpm/cargo steps directly — use this when no container engine is available or act cannot reach the daemon. Install (container mode): -| OS | Docker | act | -| ------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| macOS | [Docker Desktop](https://www.docker.com/products/docker-desktop/) | `brew install act` | -| Linux | Docker engine | [act releases](https://github.com/nektos/act/releases) | -| Windows | Docker Desktop (WSL2 backend) | `choco install act-cli` or [releases](https://github.com/nektos/act/releases) | +| OS | Container engine | act | +| ------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| macOS | [Podman Desktop](https://podman.io/) with Docker compatibility enabled | `brew install act` | +| Linux | Podman or Docker engine | [act releases](https://github.com/nektos/act/releases) | +| Windows | [Podman Desktop](https://podman.io/) with Docker compatibility enabled | `choco install act-cli` or [releases](https://github.com/nektos/act/releases) | ```bash pnpm run act:pull-images # container mode only @@ -653,7 +653,7 @@ pnpm run act:ci # or act:ci:native pnpm run act:tests # or act:tests:native ``` -Docker Desktop on macOS: act auto-uses `~/.docker/run/docker.sock`. Set `ACT_DOCKER_SOCKET` if act still fails. +On macOS, podman exposes a Docker-compatible socket at `/var/run/docker.sock`; pass that path to `act` via `ACT_DOCKER_SOCKET`, or let it auto-detect if Podman created the socket symlink. If you use Docker Desktop instead of Podman, its socket is typically under `~/.docker/run/docker.sock`. macOS and Windows **installer** builds still require native `pnpm run dist:mac` / `pnpm run dist:win`. See [ci-cd.md](ci-cd.md#running-ci-locally-with-act). @@ -732,6 +732,10 @@ pnpm install pnpm run dev ``` +### Local CI container engine + +[Podman Desktop](https://podman.io/) is the preferred cross-platform Docker-compatible engine for local CI. When Docker compatibility is enabled, Podman exposes a Docker-compatible socket at `/var/run/docker.sock`; pass that path to `act` via `ACT_DOCKER_SOCKET`, or let it auto-detect if Podman Desktop created the socket symlink. If you use Docker Desktop instead, see the socket note above. + ### Bluetooth permissions On first BLE connection, macOS prompts for Bluetooth access. If denied accidentally: diff --git a/scripts/check-environment.mjs b/scripts/check-environment.mjs index dbac4060..6c73836e 100644 --- a/scripts/check-environment.mjs +++ b/scripts/check-environment.mjs @@ -433,40 +433,102 @@ function checkYamllint() { }; } -function checkDocker() { - const out = commandOutput('docker', ['--version']); - if (!out) { +/** + * Evaluate optional container-engine readiness for act. + * Prefers Podman when its daemon is ready (`podmanOk`); otherwise Docker when ready. + * Matches `resolveContainerEngine()` in scripts/run-act.mjs. + * + * @param {{ + * dockerPath: string | null, + * podmanPath: string | null, + * dockerOk: boolean, + * podmanOk: boolean, + * dockerVersion: string | null, + * podmanVersion: string | null, + * hasPodmanApp?: boolean, + * hasDockerApp?: boolean, + * socket?: string | undefined, + * }} input + * @returns {CheckResult} + */ +export function evaluateContainerEngineCheck(input) { + const { + dockerPath, + podmanPath, + dockerOk, + podmanOk, + dockerVersion, + podmanVersion, + hasPodmanApp = false, + hasDockerApp = false, + socket, + } = input; + + // Prefer Podman when its daemon is ready; else Docker when ready (daemon-gated). + const preferred = podmanOk ? 'podman' : dockerOk ? 'docker' : null; + if (preferred) { + const versionOut = preferred === 'podman' ? podmanVersion : dockerVersion; + const versionLine = (versionOut ?? preferred).split('\n')[0]; + const socketNote = socket + ? `; act socket ${socket}` + : '; act socket not detected (set ACT_DOCKER_SOCKET if act fails)'; return { - status: 'warn', + status: 'pass', severity: 'optional', - label: 'Docker not found (optional)', - hint: 'For container CI (act): install Docker Desktop or engine. Or use host CI: pnpm run act:ci:native — see docs/ci-cd.md', + label: 'Container engine', + detail: `${versionLine}${socketNote}`, }; } - if (!commandOk('docker', ['info'])) { + // CLI on PATH but daemon/info failed — do not report as "not found". + const presentCli = podmanPath ? 'podman' : dockerPath ? 'docker' : null; + if (presentCli) { + const versionOut = presentCli === 'podman' ? podmanVersion : dockerVersion; return { status: 'warn', severity: 'optional', - label: 'Docker daemon not running (optional)', - detail: out.split('\n')[0], - hint: 'Start Docker Desktop for pnpm run act:* (container mode), or use pnpm run act:ci:native on the host', + label: 'Container engine not running (optional)', + detail: versionOut ? versionOut.split('\n')[0] : presentCli, + hint: 'Start Podman Desktop (preferred) or Docker Desktop for pnpm run act:* (container mode), or use pnpm run act:ci:native on the host', }; } - const socket = resolveDockerSocket(); - const socketNote = socket - ? `; act socket ${socket}` - : '; act socket not detected (set ACT_DOCKER_SOCKET if act fails with Docker Desktop)'; - + let hint = + 'For container CI (act): install Podman Desktop (preferred) or Docker Desktop. Or use host CI: pnpm run act:ci:native — see docs/ci-cd.md'; + if (hasPodmanApp) { + hint += + '\n → Podman Desktop is installed but not on PATH. Add /opt/podman/bin to your shell rc, then source it or restart your shell.'; + } else if (hasDockerApp) { + hint += + '\n → Docker Desktop is installed but not on PATH. Add its CLI to PATH or restart your shell.'; + } return { - status: 'pass', + status: 'warn', severity: 'optional', - label: 'Docker', - detail: `${out.split('\n')[0]}${socketNote}`, + label: 'Container engine not found (optional)', + hint, }; } +function checkDocker() { + const dockerPath = commandOutput('which', ['docker']); + const podmanPath = commandOutput('which', ['podman']); + const dockerOk = Boolean(dockerPath && commandOk('docker', ['info'])); + const podmanOk = Boolean(podmanPath && commandOk('podman', ['info'])); + + return evaluateContainerEngineCheck({ + dockerPath, + podmanPath, + dockerOk, + podmanOk, + dockerVersion: dockerPath ? commandOutput('docker', ['--version']) : null, + podmanVersion: podmanPath ? commandOutput('podman', ['--version']) : null, + hasPodmanApp: existsSync('/opt/podman/bin/podman'), + hasDockerApp: existsSync('/Applications/Docker.app'), + socket: resolveDockerSocket(), + }); +} + function checkAct() { const out = commandOutput('act', ['--version']); if (!out) { @@ -474,7 +536,7 @@ function checkAct() { status: 'warn', severity: 'optional', label: 'act not found (optional)', - hint: 'For container CI: install act + Docker, then pnpm run act:pull-images. Or use host CI: pnpm run act:ci:native', + hint: 'For container CI: install act + Podman/Docker, then pnpm run act:pull-images. Or use host CI: pnpm run act:ci:native', }; } return { @@ -490,29 +552,31 @@ function checkAct() { * @returns {string | null} */ export function formatLocalActDockerNote(checks) { - const docker = checks.find((c) => c.label.startsWith('Docker')); + const containerEngine = checks.find( + (c) => c.label.startsWith('Container engine') || c.label.startsWith('Docker'), + ); const actCheck = checks.find((c) => c.label === 'act' || c.label.startsWith('act not found')); - if (!docker && !actCheck) { + if (!containerEngine && !actCheck) { return null; } - const dockerReady = docker?.status === 'pass'; + const containerReady = containerEngine?.status === 'pass'; const actReady = actCheck?.status === 'pass'; - if (dockerReady && actReady) { - return 'ℹ️ Container CI: pnpm run act:ci (act + Docker). Host CI (no Docker): pnpm run act:ci:native. See docs/ci-cd.md.'; + if (containerReady && actReady) { + return 'ℹ️ Container CI: pnpm run act:ci (act + Podman/Docker). Host CI (no containers): pnpm run act:ci:native. See docs/ci-cd.md.'; } - if (actReady && !dockerReady) { - return 'ℹ️ act is installed but Docker is not ready — start Docker Desktop for act:* or use pnpm run act:ci:native on the host.'; + if (actReady && !containerReady) { + return 'ℹ️ act is installed but the container engine is not ready — start Podman Desktop (preferred) or Docker Desktop for act:* or use pnpm run act:ci:native on the host.'; } - if (dockerReady && !actReady) { - return 'ℹ️ Docker is ready; install act for container workflows (pnpm run act:ci) or use pnpm run act:ci:native on the host.'; + if (containerReady && !actReady) { + return 'ℹ️ Container engine is ready; install act for container workflows (pnpm run act:ci) or use pnpm run act:ci:native on the host.'; } - return 'ℹ️ Local CI: pnpm run act:ci:native (host) or install act + Docker for container workflows. See docs/ci-cd.md.'; + return 'ℹ️ Local CI: pnpm run act:ci:native (host) or install act + Docker/Podman for container workflows. See docs/ci-cd.md.'; } function checkLinuxDialout() { diff --git a/scripts/check-environment.test.mjs b/scripts/check-environment.test.mjs index 26cf5f5e..a596551f 100644 --- a/scripts/check-environment.test.mjs +++ b/scripts/check-environment.test.mjs @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { formatCheckResult, formatLocalActDockerNote, + evaluateContainerEngineCheck, parseVersion, resolveExitCode, versionGte, @@ -77,10 +78,10 @@ describe('check-environment formatCheckResult', () => { formatCheckResult({ status: 'warn', severity: 'optional', - label: 'Docker not found (optional)', - hint: 'Install Docker', + label: 'Container engine not found (optional)', + hint: 'Install Docker or Podman', }), - ).toEqual(['⚠️ Docker not found (optional)', ' → Install Docker']); + ).toEqual(['⚠️ Container engine not found (optional)', ' → Install Docker or Podman']); }); }); @@ -114,40 +115,122 @@ describe('check-environment resolveExitCode', () => { }); describe('check-environment formatLocalActDockerNote', () => { - it('returns null when neither docker nor act checks are present', () => { + it('returns null when neither container engine nor act checks are present', () => { expect( formatLocalActDockerNote([{ status: 'pass', severity: 'required', label: 'Git' }]), ).toBeNull(); }); - it('returns paired missing note when docker or act warns', () => { + it('returns paired missing note when container engine or act warns', () => { expect( formatLocalActDockerNote([ - { status: 'warn', severity: 'optional', label: 'Docker not found (optional)' }, + { status: 'warn', severity: 'optional', label: 'Container engine not found (optional)' }, { status: 'pass', severity: 'optional', label: 'act', detail: '0.2.0' }, ]), ).toContain('act:ci:native'); }); - it('returns ready note when both docker and act pass', () => { + it('returns ready note when both container engine and act pass', () => { expect( formatLocalActDockerNote([ - { status: 'pass', severity: 'optional', label: 'Docker', detail: 'Docker 27' }, + { status: 'pass', severity: 'optional', label: 'Container engine', detail: 'Podman 5.8.5' }, { status: 'pass', severity: 'optional', label: 'act', detail: 'act version 0.2.76' }, ]), ).toContain('act:ci:native'); }); - it('suggests native mode when act is ready but docker is not', () => { + it('suggests native mode when act is ready but container engine is not', () => { expect( formatLocalActDockerNote([ { status: 'warn', severity: 'optional', - label: 'Docker daemon not running (optional)', + label: 'Container engine not running (optional)', }, { status: 'pass', severity: 'optional', label: 'act', detail: 'act version 0.2.76' }, ]), ).toContain('act:ci:native'); }); }); + +describe('check-environment evaluateContainerEngineCheck', () => { + it('prefers Podman when both engines are ready', () => { + const result = evaluateContainerEngineCheck({ + dockerPath: '/usr/local/bin/docker', + podmanPath: '/opt/podman/bin/podman', + dockerOk: true, + podmanOk: true, + dockerVersion: 'Docker version 28.0.0', + podmanVersion: 'podman version 5.8.5', + socket: '/var/run/docker.sock', + }); + expect(result).toMatchObject({ + status: 'pass', + label: 'Container engine', + }); + expect(result.detail).toContain('podman version 5.8.5'); + expect(result.detail).toContain('/var/run/docker.sock'); + expect(result.detail).not.toContain('Docker version'); + }); + + it('falls back to Docker when only Docker is ready', () => { + const result = evaluateContainerEngineCheck({ + dockerPath: '/usr/local/bin/docker', + podmanPath: null, + dockerOk: true, + podmanOk: false, + dockerVersion: 'Docker version 28.0.0', + podmanVersion: null, + socket: '/Users/test/.docker/run/docker.sock', + }); + expect(result.status).toBe('pass'); + expect(result.detail).toContain('Docker version 28.0.0'); + }); + + it('reports not running when a CLI is present but info fails', () => { + const result = evaluateContainerEngineCheck({ + dockerPath: '/usr/local/bin/docker', + podmanPath: null, + dockerOk: false, + podmanOk: false, + dockerVersion: 'Docker version 28.0.0', + podmanVersion: null, + }); + expect(result).toMatchObject({ + status: 'warn', + label: 'Container engine not running (optional)', + detail: 'Docker version 28.0.0', + }); + expect(result.hint).toContain('Podman Desktop'); + }); + + it('prefers Podman detail when both CLIs are present but neither info succeeds', () => { + const result = evaluateContainerEngineCheck({ + dockerPath: '/usr/local/bin/docker', + podmanPath: '/opt/podman/bin/podman', + dockerOk: false, + podmanOk: false, + dockerVersion: 'Docker version 28.0.0', + podmanVersion: 'podman version 5.8.5', + }); + expect(result.label).toBe('Container engine not running (optional)'); + expect(result.detail).toBe('podman version 5.8.5'); + }); + + it('reports not found when no CLI is on PATH', () => { + const result = evaluateContainerEngineCheck({ + dockerPath: null, + podmanPath: null, + dockerOk: false, + podmanOk: false, + dockerVersion: null, + podmanVersion: null, + hasPodmanApp: true, + }); + expect(result).toMatchObject({ + status: 'warn', + label: 'Container engine not found (optional)', + }); + expect(result.hint).toContain('/opt/podman/bin'); + }); +}); diff --git a/scripts/run-act.mjs b/scripts/run-act.mjs index c5e77b8d..d2a9b2f5 100644 --- a/scripts/run-act.mjs +++ b/scripts/run-act.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node /** - * Run GitHub Actions workflows locally via nektos/act + Docker, or run equivalent - * checks natively on the host (no Docker). Linux runner jobs only for act mode. + * Run GitHub Actions workflows locally via nektos/act + a Docker-compatible + * container engine (Podman preferred), or run equivalent checks natively on the + * host (no container engine). Linux runner jobs only for act mode. */ import { spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; @@ -18,6 +19,29 @@ const repoRoot = resolve(__dirname, '..'); export const ACT_MODE_ENV = 'MESH_CLIENT_ACT_MODE'; +function commandOk(command, args) { + const res = spawnSync(command, args, { stdio: 'ignore' }); + return res.status === 0; +} + +/** + * Prefer a ready Podman daemon for act container CI; fall back to Docker when + * Podman is missing or its machine/daemon is not ready (`podman info` fails). + * @param {{ commandOk?: (command: string, args: string[]) => boolean }} [options] + * @returns {'podman' | 'docker'} + */ +export function resolveContainerEngine(options = {}) { + const ok = options.commandOk ?? commandOk; + if (ok('podman', ['info'])) return 'podman'; + if (ok('docker', ['info'])) return 'docker'; + // Neither daemon ready: keep Podman for preflight messaging when its CLI exists. + return ok('podman', ['--version']) ? 'podman' : 'docker'; +} + +/** + * Prefer Podman for act container CI; fall back to Docker only if Podman is unavailable. + */ +export const CONTAINER_ENGINE = resolveContainerEngine(); export const ACT_PLATFORM_IMAGE = 'ghcr.io/catthehacker/ubuntu:full-latest'; export const FLATPAK_CONTAINER_IMAGE = 'ghcr.io/flathub-infra/flatpak-github-actions:freedesktop-24.08'; @@ -40,20 +64,17 @@ export const ACT_TARGETS = { event: 'workflow_dispatch', workflow: '.github/workflows/build.yaml', job: 'build', - matrix: ['os=ubuntu-latest'], }, 'reticulum-sidecar': [ { event: 'workflow_dispatch', workflow: '.github/workflows/reticulum-sidecar.yaml', job: 'build', - matrix: ['os=ubuntu-latest'], }, { event: 'workflow_dispatch', workflow: '.github/workflows/reticulum-sidecar.yaml', job: 'build-rns-stack', - matrix: ['os=ubuntu-latest'], }, ], flatpak: [ @@ -61,13 +82,11 @@ export const ACT_TARGETS = { event: 'workflow_dispatch', workflow: '.github/workflows/flatpak.yaml', job: 'reticulum-sidecar', - matrix: ['arch=x86_64'], }, { event: 'workflow_dispatch', workflow: '.github/workflows/flatpak.yaml', job: 'flatpak', - matrix: ['arch=x86_64'], containerOptions: '--privileged', }, ], @@ -165,7 +184,9 @@ export function parseArgv(argv, envValue = process.env[ACT_MODE_ENV]) { } /** - * Resolve Docker socket path for act (Docker Desktop often uses ~/.docker/run/docker.sock). + * Resolve container-engine socket path for act. + * Preferred engine: Podman Desktop, typically at /var/run/docker.sock. + * Fallback engine: Docker Desktop, often at ~/.docker/run/docker.sock. * @param {{ homeDir?: string, platform?: string, env?: NodeJS.ProcessEnv }} [options] * @returns {string | undefined} */ @@ -259,11 +280,6 @@ export function buildActArgs(invocation, options = {}) { return args; } -function commandOk(command, args) { - const res = spawnSync(command, args, { stdio: 'ignore' }); - return res.status === 0; -} - function toolAvailable(tool) { const checker = process.platform === 'win32' ? 'where' : 'which'; return commandOk(checker, [tool]); @@ -276,29 +292,33 @@ function commandOutput(command, args) { } function printInstallHints() { - console.error('Install act + Docker to run GitHub Actions in containers:'); - console.error(' macOS: brew install act && Docker Desktop'); - console.error(' Linux: https://github.com/nektos/act/releases + Docker engine'); - console.error(' Windows: choco install act-cli && Docker Desktop (WSL2 backend)'); + console.error( + 'Install act + a Docker-compatible container engine to run GitHub Actions in containers:', + ); + console.error(' macOS: brew install act && Podman Desktop'); + console.error(' Linux: https://github.com/nektos/act/releases + Podman or Docker engine'); + console.error(' Windows: choco install act-cli && Podman Desktop'); console.error(''); - console.error('Or run on the host without Docker: pnpm run act:ci:native'); - console.error('Then (docker mode): pnpm run act:pull-images'); + console.error('Or run on the host without a container engine: pnpm run act:ci:native'); + console.error('Then (container mode): pnpm run act:pull-images'); console.error('Docs: docs/ci-cd.md'); } -function preflightActAndDocker() { - const dockerVersion = commandOutput('docker', ['--version']); - if (!dockerVersion) { - console.error('❌ Docker not found or not on PATH.'); +function preflightActAndContainerEngine() { + const containerVersion = commandOutput(CONTAINER_ENGINE, ['--version']); + if (!containerVersion) { + console.error( + '❌ No container engine found (need a Docker-compatible engine, e.g. Podman Desktop or Docker Desktop).', + ); printInstallHints(); process.exit(1); } - if (!commandOk('docker', ['info'])) { - console.error('❌ Docker daemon is not running.'); - console.error(` (${dockerVersion})`); - console.error('Start Docker Desktop or the Docker engine, then retry.'); - console.error('Or use native mode: pnpm run act:ci:native'); + if (!commandOk(CONTAINER_ENGINE, ['info'])) { + console.error(`❌ Container engine is not running (${containerVersion.split('\n')[0]}).`); + console.error( + ` Start Podman Desktop (preferred) / Docker Desktop, or use native mode: pnpm run act:ci:native`, + ); process.exit(1); } @@ -314,19 +334,19 @@ function preflightActAndDocker() { console.log(`Using Docker socket for act: ${dockerSocket}`); } else { console.warn('⚠️ Could not detect Docker socket; act will use its default.'); - console.warn(' Set ACT_DOCKER_SOCKET if act cannot reach Docker Desktop.'); + console.warn(' Set ACT_DOCKER_SOCKET if act cannot reach the container engine.'); } - return { dockerVersion, actVersion, dockerSocket }; + return { containerVersion, actVersion, dockerSocket }; } /** * @param {string[]} dockerArgs * @returns {number} */ -function runDocker(dockerArgs) { - console.log(`$ docker ${dockerArgs.join(' ')}`); - const res = spawnSync('docker', dockerArgs, { cwd: repoRoot, stdio: 'inherit' }); +function runContainerEngine(containerArgs) { + console.log(`$ ${CONTAINER_ENGINE} ${containerArgs.join(' ')}`); + const res = spawnSync(CONTAINER_ENGINE, containerArgs, { cwd: repoRoot, stdio: 'inherit' }); return res.status ?? 1; } @@ -402,7 +422,7 @@ export function runNativeTarget(target) { return 1; } - console.log(`Running native CI steps for "${target}" (host, no Docker)…`); + console.log(`Running native CI steps for "${target}" (host, no container engine)…`); for (const step of steps) { const code = runNativeStep(step); if (code !== 0) { @@ -413,9 +433,9 @@ export function runNativeTarget(target) { } function pullImages() { - preflightActAndDocker(); + preflightActAndContainerEngine(); for (const image of ACT_PULL_IMAGES) { - const code = runDocker(['pull', image]); + const code = runContainerEngine(['pull', image]); if (code !== 0) { process.exit(code); } @@ -454,7 +474,7 @@ export function runActTarget(target, options = {}) { return 0; } - const { dockerSocket } = preflightActAndDocker(); + const { dockerSocket } = preflightActAndContainerEngine(); const config = ACT_TARGETS[target]; if (!config) { @@ -489,8 +509,8 @@ function printUsage() { console.log(`Usage: node scripts/run-act.mjs [--native|--docker] [-- extra act args] Modes (default: docker / act in containers): - --docker Run workflow jobs via act + Docker (Docker Desktop supported) - --native Run equivalent pnpm/cargo commands on the host (no Docker) + --docker Run workflow jobs via act + containers (any Docker-compatible engine, e.g. Podman Desktop or Docker Desktop) + --native Run equivalent pnpm/cargo commands on the host (no container engine) MESH_CLIENT_ACT_MODE=native|docker Targets: diff --git a/scripts/run-act.test.mjs b/scripts/run-act.test.mjs index b7ab914d..01660a40 100644 --- a/scripts/run-act.test.mjs +++ b/scripts/run-act.test.mjs @@ -9,9 +9,36 @@ import { parseActMode, parseArgv, resolveContainerArch, + resolveContainerEngine, resolveDockerSocket, } from './run-act.mjs'; +describe('run-act resolveContainerEngine', () => { + it('prefers Podman when podman info succeeds', () => { + const commandOk = (command, args) => command === 'podman' && args[0] === 'info'; + expect(resolveContainerEngine({ commandOk })).toBe('podman'); + }); + + it('falls back to Docker when Podman CLI exists but info fails and Docker is ready', () => { + const commandOk = (command, args) => { + if (command === 'podman' && args[0] === '--version') return true; + if (command === 'docker' && args[0] === 'info') return true; + return false; + }; + expect(resolveContainerEngine({ commandOk })).toBe('docker'); + }); + + it('keeps Podman when neither daemon is ready but Podman CLI exists', () => { + const commandOk = (command, args) => command === 'podman' && args[0] === '--version'; + expect(resolveContainerEngine({ commandOk })).toBe('podman'); + }); + + it('uses Docker when Podman is absent', () => { + const commandOk = () => false; + expect(resolveContainerEngine({ commandOk })).toBe('docker'); + }); +}); + describe('run-act parseActMode', () => { it('defaults to docker', () => { expect(parseActMode(undefined)).toBe('docker'); @@ -156,14 +183,13 @@ describe('run-act buildActArgs', () => { ]); }); - it('includes matrix and privileged container options for flatpak', () => { + it('includes privileged container options for flatpak', () => { expect( buildActArgs( { event: 'workflow_dispatch', workflow: '.github/workflows/flatpak.yaml', job: 'flatpak', - matrix: ['arch=x86_64'], containerOptions: '--privileged', }, { hostArch: 'arm64', dockerSocket: '/var/run/docker.sock' }, @@ -179,8 +205,6 @@ describe('run-act buildActArgs', () => { '.github/workflows/flatpak.yaml', '-j', 'flatpak', - '--matrix', - 'arch=x86_64', '--container-options', '--privileged', 'workflow_dispatch',