ci: migrate backend/frontend/e2e workflows to kandev-ci base image (#1115)

This commit is contained in:
Carlos Florêncio
2026-05-28 13:11:33 +01:00
committed by GitHub
parent 3b1b3473f1
commit 15721860e9
5 changed files with 194 additions and 64 deletions
+36 -11
View File
@@ -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: |
+110 -32
View File
@@ -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
+21 -16
View File
@@ -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
@@ -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)
}
})
}
@@ -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())