From 15721860e99cec4c0d3ceb8a884fcb76304b576e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 28 May 2026 13:11:33 +0100 Subject: [PATCH] ci: migrate backend/frontend/e2e workflows to kandev-ci base image (#1115) --- .github/workflows/backend-tests.yml | 47 ++++-- .github/workflows/e2e-tests.yml | 142 ++++++++++++++---- .github/workflows/frontend-tests.yml | 37 +++-- .../event_handlers_pending_move_test.go | 16 +- .../internal/system/disk/handler_test.go | 16 +- 5 files changed, 194 insertions(+), 64 deletions(-) diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index d4ebbaa2f2..003083a04b 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -22,12 +22,29 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Least-privilege default. The Linux `test` job and the Windows `test-windows` +# job only read source and run tests — no PR/issue writes, no release ops. +# `packages: read` is required for the Linux job to pull the kandev-ci +# container image from GHCR (currently public; explicit grant keeps the +# workflow working if the package ever goes private). Declaring any scope +# explicitly zeroes out every unlisted one. +permissions: + contents: read + packages: read + jobs: test: name: Run Backend Tests runs-on: ubuntu-latest + # Pre-baked image bundles Go, golangci-lint, go-licenses, Docker CLI, Node, + # pnpm, and the Playwright base — see .github/docker/ci-base/Dockerfile. + # Skips the per-run setup-go / golangci-lint download steps below. + container: ghcr.io/kdlbs/kandev-ci:latest defaults: run: + # Container jobs default to `sh` (dash); pin to bash so `set -o pipefail` + # and other bash-isms in the steps below work without per-step overrides. + shell: bash working-directory: apps/backend steps: @@ -36,14 +53,20 @@ jobs: with: fetch-depth: 0 - - name: Set up Go - if: ${{ !contains(runner.name, 'homelab') }} - uses: actions/setup-go@v6 - with: - go-version-file: apps/backend/go.mod - cache-dependency-path: apps/backend/go.sum + # actions/checkout under a container job marks the workspace as a safe + # directory automatically, but subsequent `git` invocations from steps + # that run outside the action (e.g. the gofmt diff below) can still hit + # "dubious ownership" when the runner UID differs from the repo owner. + - name: Mark workspace safe for git + shell: bash + working-directory: ${{ github.workspace }} + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Cache Go modules + # Homelab self-hosted runners (kept for future re-enablement; the + # `container:` above forces the job onto a github-hosted runner today, + # so this condition is dormant). If homelab is reactivated, split this + # job into a matrix where the homelab variant omits `container:`. if: ${{ contains(runner.name, 'homelab') }} uses: actions/cache@v4 with: @@ -100,11 +123,13 @@ jobs: fi - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.9 - args: ./... --new-from-rev=${{ env.BASE_SHA }} - working-directory: apps/backend + # golangci-lint is preinstalled in the kandev-ci image; invoke it + # directly instead of going through `golangci/golangci-lint-action`, + # which would re-download its own binary on every run. `--timeout=5m` + # matches the action's previous implicit default (the binary's own + # default is 1m, which can trip on cold caches). + shell: bash + run: golangci-lint run ./... --new-from-rev="${BASE_SHA}" --timeout=5m - name: Run tests run: | diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 85921e43ff..51a4cb2948 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -29,32 +29,39 @@ concurrency: permissions: contents: read + # Required so the containerized build / e2e shards / e2e-report jobs can + # pull the kandev-ci image from GHCR via GITHUB_TOKEN. + packages: read jobs: build: name: Build runs-on: ubuntu-latest + # Pre-baked image bundles Go, Node 24, pnpm 9.15.9 — see + # .github/docker/ci-base/Dockerfile. + container: ghcr.io/kdlbs/kandev-ci:latest timeout-minutes: 10 + # Container jobs default to `sh`; pin to bash for the few shell steps below. + defaults: + run: + shell: bash steps: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: apps/backend/go.mod - cache-dependency-path: apps/backend/go.sum + - name: Mark workspace safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Set up pnpm - uses: pnpm/action-setup@v5 + # Inside the container the home dir is /root, so the pnpm store lives at + # /root/.local/share/pnpm/store. actions/cache resolves `~` against $HOME + # so the same path string works for both the host and container variants. + - name: Cache pnpm store + uses: actions/cache@v4 with: - version: "9.15.9" - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('apps/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - name: Install dependencies working-directory: apps @@ -92,7 +99,33 @@ jobs: name: E2E Shard ${{ matrix.shard }}/10 needs: build runs-on: ubuntu-latest + # Pre-baked image bundles Node 24, pnpm 9.15.9, and the Playwright browser + # binaries (Chromium etc.) — see .github/docker/ci-base/Dockerfile. This is + # the biggest speedup since `npx playwright install chromium --with-deps` + # alone took ~1.5–2 min per shard. + # + # `--ipc=host` is required for Chromium: the default container `/dev/shm` + # is only ~64MiB, which causes tabs to slow to a crawl and renderers to + # crash mid-test. + container: + image: ghcr.io/kdlbs/kandev-ci:latest + options: --ipc=host timeout-minutes: 22 + # Container jobs default to `sh`; pin to bash. + defaults: + run: + shell: bash + # Node 18+'s `dns.lookup` defaults to `verbatim: true`, returning the OS + # resolver order. Inside this container `localhost` resolves to ::1 first, + # so `net.Server.listen({ host: "localhost" })` (used by Next.js standalone + # via $HOSTNAME) binds IPv6-only — and undici's `fetch("http://localhost…")` + # in the test runner only tries the first resolved address (IPv4), so the + # frontend health check times out and every spec fails. Forcing + # IPv4-first ordering aligns both the bind and the fetch and matches the + # effective default on the github-hosted ubuntu runner this workflow + # previously ran on. + env: + NODE_OPTIONS: --dns-result-order=ipv4first strategy: fail-fast: false matrix: @@ -102,24 +135,20 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up pnpm - uses: pnpm/action-setup@v5 - with: - version: "9.15.9" + - name: Mark workspace safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Set up Node.js - uses: actions/setup-node@v6 + - name: Cache pnpm store + uses: actions/cache@v4 with: - node-version: "24" + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('apps/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - name: Install dependencies working-directory: apps run: pnpm install --frozen-lockfile - - name: Install Playwright browsers - working-directory: apps/web - run: npx playwright install chromium --with-deps - - name: Download backend build uses: actions/download-artifact@v5 with: @@ -178,6 +207,11 @@ jobs: e2e-containers: name: E2E Containers Shard ${{ matrix.shard }}/6 needs: build + # This job intentionally runs on the host ubuntu runner (NOT the kandev-ci + # container image): the containers project needs direct access to the + # host Docker daemon, and docker/setup-buildx style steps + the + # docker-in-container UX are awkward inside a container job. We recover + # some of the setup cost via the pnpm-store and Playwright caches below. runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -202,11 +236,42 @@ jobs: with: node-version: "24" + - name: Cache pnpm store + uses: actions/cache@v4 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('apps/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + + # Derive the Playwright version from apps/web/package.json at runtime so + # the cache key below auto-busts on every @playwright/test bump. The + # previous hardcoded `1.58.2` suffix would have silently served stale + # browser binaries until manually updated. + - name: Resolve Playwright version + id: pw + shell: bash + run: | + set -euo pipefail + raw=$(node -p "require('./apps/web/package.json').devDependencies['@playwright/test']") + version=$(printf '%s' "$raw" | sed -E 's/^[^0-9]*//; s/[^0-9.].*$//') + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "Playwright version: ${version}" + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }} + - name: Install dependencies working-directory: apps run: pnpm install --frozen-lockfile + # `--with-deps` runs apt-get install on every invocation (~20–30s), so + # skip the step entirely when the cache restored the browser binaries. - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' working-directory: apps/web run: npx playwright install chromium --with-deps @@ -263,21 +328,27 @@ jobs: needs: [e2e, e2e-containers] if: always() runs-on: ubuntu-latest + # Pre-baked image bundles Node 24, pnpm 9.15.9, and the Playwright CLI. + container: ghcr.io/kdlbs/kandev-ci:latest timeout-minutes: 5 + # Container jobs default to `sh`; pin to bash. + defaults: + run: + shell: bash steps: - name: Checkout code uses: actions/checkout@v6 - - name: Set up pnpm - uses: pnpm/action-setup@v5 - with: - version: "9.15.9" + - name: Mark workspace safe for git + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Set up Node.js - uses: actions/setup-node@v6 + - name: Cache pnpm store + uses: actions/cache@v4 with: - node-version: "24" + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('apps/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - name: Install dependencies working-directory: apps @@ -292,7 +363,14 @@ jobs: - name: Merge reports working-directory: apps/web - run: npx playwright merge-reports --reporter=html ./e2e/blob-reports + # `-c e2e/playwright.config.ts` is required because the blobs come from + # two different absolute checkout paths: the containerized `e2e` shards + # check out under `/__w/...` while `e2e-containers` runs on the host + # runner and checks out under `/home/runner/work/...`. Playwright + # embeds the absolute testDir in each blob and refuses to merge + # reports recorded with different test directories — passing the + # config makes it normalize against the config's `testDir` instead. + run: npx playwright merge-reports --config e2e/playwright.config.ts --reporter=html ./e2e/blob-reports - name: Upload merged report uses: actions/upload-artifact@v5 diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index b6ac92b83b..c00c57b5c7 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -38,12 +38,24 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Least-privilege: the `frontend` job only reads source and runs tests. +# `packages: read` lets the container job pull the kandev-ci image from GHCR. +permissions: + contents: read + packages: read + jobs: frontend: name: Run Frontend Lint, Tests, and Build runs-on: ubuntu-latest + # Pre-baked image bundles Node 24, pnpm 9.15.9, Go, and go-licenses — + # see .github/docker/ci-base/Dockerfile. + container: ghcr.io/kdlbs/kandev-ci:latest defaults: run: + # Container jobs default to `sh` (dash); pin to bash for `set -euo + # pipefail` and other bash-isms used by the formatting / licenses steps. + shell: bash working-directory: apps steps: @@ -52,28 +64,21 @@ jobs: with: fetch-depth: 0 - - name: Set up pnpm - uses: pnpm/action-setup@v5 - with: - version: "9.15.9" + - name: Mark workspace safe for git + shell: bash + working-directory: ${{ github.workspace }} + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Set up Node.js - uses: actions/setup-node@v6 + - name: Cache pnpm store + uses: actions/cache@v4 with: - node-version: "24" + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('apps/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version-file: apps/backend/go.mod - cache-dependency-path: apps/backend/go.sum - - - name: Install go-licenses - run: go install github.com/google/go-licenses@latest - - name: Regenerate OSS licenses manifest run: pnpm --filter @kandev/web licenses:gen diff --git a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go index d6fecf04cf..060e412884 100644 --- a/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_pending_move_test.go @@ -701,14 +701,22 @@ func TestHandleAgentBootReady_DrainsOrphanedQueuedMessage(t *testing.T) { t.Errorf("queue count after boot ready = %d, want 0 (orphaned message must be drained)", got) } - // Session must still end up WAITING_FOR_INPUT — the drain is - // additive, not a replacement for the existing flip behavior. + // The handler synchronously flips the session to WAITING_FOR_INPUT + // (line 173 of event_handlers_agent.go) and then spawns + // executeQueuedMessage in a goroutine; that goroutine calls + // PromptTask which immediately moves state to RUNNING. We can race + // with that goroutine on slow CI runners, so accept either + // WAITING_FOR_INPUT (goroutine hasn't transitioned yet) or RUNNING + // (goroutine got ahead of us). Either proves the boot-ready flip + // landed; the orphaned-message regression we guard against would + // leave state stuck on STARTING with the queue still full. finalSess, err := repo.GetTaskSession(ctx, sessionID) if err != nil { t.Fatalf("load session: %v", err) } - if finalSess.State != models.TaskSessionStateWaitingForInput { - t.Errorf("session.State = %q, want WAITING_FOR_INPUT", finalSess.State) + if finalSess.State != models.TaskSessionStateWaitingForInput && + finalSess.State != models.TaskSessionStateRunning { + t.Errorf("session.State = %q, want WAITING_FOR_INPUT or RUNNING (post-flip, possibly post-goroutine)", finalSess.State) } }) } diff --git a/apps/backend/internal/system/disk/handler_test.go b/apps/backend/internal/system/disk/handler_test.go index 46a57210e5..bba3a1ff5c 100644 --- a/apps/backend/internal/system/disk/handler_test.go +++ b/apps/backend/internal/system/disk/handler_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os/exec" "path/filepath" "runtime" "testing" @@ -39,11 +40,24 @@ func TestHandleOpenFolder_NoHomeDirReturns503(t *testing.T) { } func TestHandleOpenFolder_ReturnsPathOnSupportedOS(t *testing.T) { + var opener string switch runtime.GOOS { - case "linux", "darwin", "windows": + case "linux": + opener = "xdg-open" + case "darwin": + opener = "open" + case "windows": + opener = "explorer" default: t.Skipf("open-folder handler not exercised on %s", runtime.GOOS) } + // The handler shells out to the platform's file-manager opener and only + // returns 200 when the binary is actually present on PATH. Minimal CI + // images (e.g. our `kandev-ci` container) omit `xdg-utils`, which makes + // the assertion impossible — skip rather than asserting a false-positive. + if _, err := exec.LookPath(opener); err != nil { + t.Skipf("%s not on PATH (%v); handler integration cannot be exercised here", opener, err) + } home := t.TempDir() svc := NewService(home, jobs.NewTracker(nil, logger.Default()), logger.Default())