Files
kandev/.github/workflows/backend-tests.yml
T
2026-08-11 19:33:50 +01:00

401 lines
14 KiB
YAML

name: Backend Tests
on:
push:
branches: [main]
paths:
- "apps/backend/**"
# check-make-shells also lints the root Makefile's $(shell ...) probes, so
# a change there has to be able to trip it.
- "Makefile"
- "scripts/check-make-shells"
- ".github/workflows/backend-tests.yml"
- "!**/*.md"
- "!**/*.mdx"
- "!**/*.markdown"
pull_request:
branches: [main]
paths:
- "apps/backend/**"
- "Makefile"
- "scripts/check-make-shells"
- ".github/workflows/backend-tests.yml"
- "!**/*.md"
- "!**/*.mdx"
- "!**/*.markdown"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Least-privilege default. The Linux static/test jobs 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:
static_checks:
name: Backend Static Checks
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:build-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:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
# 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
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/go/pkg/mod
~/.cache/go-build
~/.cache/golangci-lint
key: go-static-${{ runner.os }}-${{ hashFiles('apps/backend/go.mod', 'apps/backend/go.sum') }}
restore-keys: |
go-static-${{ runner.os }}-
go-${{ runner.os }}-
- name: Build
run: go build -v ./cmd/kandev
# Assert the run/dev/start-debug targets expand to the right per-shell
# command line (native Windows / Git Bash / Unix). The windows-latest job
# below compiles Go but never exercises this Make-target dispatch, so a
# regression in the OS/MSYSTEM guards would otherwise ship unnoticed.
- name: Check Makefile shell dispatch
run: make check-make-shells
- name: Resolve base SHA
shell: bash
working-directory: ${{ github.workspace }}
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
BASE_SHA="${{ github.event.before }}"
fi
if [[ -z "${BASE_SHA}" || "${BASE_SHA}" == "0000000000000000000000000000000000000000" ]]; then
BASE_SHA="$(git rev-list --max-parents=0 HEAD)"
fi
echo "BASE_SHA=${BASE_SHA}" >> "$GITHUB_ENV"
echo "Base SHA: ${BASE_SHA}"
- name: Check gofmt
shell: bash
run: |
set -euo pipefail
CHANGED_GO_FILES="$(
git diff --name-only --diff-filter=ACMRT "${BASE_SHA}"..."${{ github.sha }}" -- apps/backend \
| grep -E '^apps/backend/.*\.go$' \
|| true
)"
if [[ -z "${CHANGED_GO_FILES}" ]]; then
echo "No backend Go files changed."
exit 0
fi
UNFORMATTED="$(echo "${CHANGED_GO_FILES}" | sed 's#^apps/backend/##' | xargs gofmt -l)"
if [[ -n "${UNFORMATTED}" ]]; then
echo "The following changed Go files are not formatted with gofmt:"
echo "${UNFORMATTED}"
exit 1
fi
- name: Run golangci-lint
# 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
test_shards:
name: Backend Tests (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- shard: 1
index: 0
- shard: 2
index: 1
# Pre-baked image bundles Go and jq, so test shards can start immediately
# without repeating tool installation.
container: ghcr.io/kdlbs/kandev-ci:build-latest
defaults:
run:
shell: bash
working-directory: apps/backend
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- 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
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-test-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('apps/backend/go.mod', 'apps/backend/go.sum') }}
restore-keys: |
go-test-${{ matrix.shard }}-${{ runner.os }}-
go-${{ runner.os }}-
- name: Run tests
env:
SHARD_INDEX: ${{ matrix.index }}
SHARD_NUMBER: ${{ matrix.shard }}
run: |
set -euo pipefail
package_list="$(go list ./...)"
if [[ -z "${package_list}" ]]; then
echo "go list returned no backend packages"
exit 1
fi
mapfile -t all_packages <<<"${package_list}"
shard_packages=()
for package in "${all_packages[@]}"; do
# Stable hashing keeps package assignments unchanged when another
# package is added or removed.
checksum="$(cksum <<<"${package}")"
checksum="${checksum%% *}"
if (( checksum % 2 == SHARD_INDEX )); then
shard_packages+=("${package}")
fi
done
if (( ${#shard_packages[@]} == 0 )); then
echo "Shard ${SHARD_NUMBER}/2 selected no packages"
exit 1
fi
echo "Running ${#shard_packages[@]} packages in shard ${SHARD_NUMBER}/2"
# Keep complete JSON for reports/artifacts while limiting live output
# to compiler/process lines and package-level pass/fail summaries.
go test \
-race \
-coverprofile="coverage-${SHARD_NUMBER}.out" \
-covermode=atomic \
-json \
"${shard_packages[@]}" \
2>&1 \
| tee "test-results-${SHARD_NUMBER}.json" \
| jq --unbuffered -Rr '
. as $line
| (try fromjson catch null) as $event
| if $event == null then
$line
elif ($event.Test == null and ($event.Action == "pass" or $event.Action == "fail")) then
"\($event.Action): \($event.Package) (\($event.Elapsed // 0)s)"
else
empty
end
'
- name: Generate test report
uses: robherley/go-test-action@2f859e0c8769d755d3174eecb9af8f64660827f3 # v1.1.0
if: always()
with:
fromJSONFile: apps/backend/test-results-${{ matrix.shard }}.json
omit: successful
- name: Upload test artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: backend-test-results-${{ matrix.shard }}
path: |
apps/backend/coverage-${{ matrix.shard }}.out
apps/backend/test-results-${{ matrix.shard }}.json
retention-days: 30
test:
name: Run Backend Tests
runs-on: ubuntu-latest
needs: [static_checks, test_shards]
if: ${{ always() }}
steps:
- name: Require backend checks to pass
env:
STATIC_CHECKS_RESULT: ${{ needs.static_checks.result }}
TEST_SHARDS_RESULT: ${{ needs.test_shards.result }}
run: |
set -euo pipefail
if [[ "${STATIC_CHECKS_RESULT}" != "success" || "${TEST_SHARDS_RESULT}" != "success" ]]; then
echo "Static checks: ${STATIC_CHECKS_RESULT}"
echo "Test shards: ${TEST_SHARDS_RESULT}"
exit 1
fi
postgres-boot:
name: Backend Postgres
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
timeout-minutes: 10
container: ghcr.io/kdlbs/kandev-ci:build-latest
services:
postgres:
# Mirrored from the digest-pinned Docker Hub image by
# ci-base-image.yml. Service images are pulled before steps begin, so
# moving this pull to GHCR removes an otherwise unwrappable Docker Hub
# timeout from job startup.
image: ghcr.io/kdlbs/kandev-ci:postgres-16@sha256:fe03a7605299a34ddf5e4f285dff78c3d7190a576b3c6b46f2fcff69f4bffd54
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
env:
POSTGRES_USER: kandev
POSTGRES_PASSWORD: kandev
POSTGRES_DB: kandev_admin
options: >-
--health-cmd "pg_isready -U kandev -d kandev_admin"
--health-interval 5s
--health-timeout 5s
--health-retries 12
defaults:
run:
shell: bash
working-directory: apps/backend
env:
KANDEV_TEST_POSTGRES_DSN: host=postgres port=5432 user=kandev password=kandev dbname=kandev_admin sslmode=disable
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Run env-gated Postgres tests
run: |
set -euo pipefail
mapfile -t postgres_packages < <(
grep -RIl 'PostgresDSNFromEnv' . --include='*_test.go' \
| xargs -r dirname \
| sort -u
)
if [[ "${#postgres_packages[@]}" -eq 0 ]]; then
echo "::error::No Postgres test packages found"
exit 1
fi
printf 'Running Postgres tests in:\n'
printf ' %s\n' "${postgres_packages[@]}"
output_file="$(mktemp)"
go test -v -race "${postgres_packages[@]}" 2>&1 | tee "${output_file}"
if ! grep -q '^=== RUN TestPostgresBootInitializesRepositories$' "${output_file}"; then
echo "::error::Postgres boot smoke test matched no tests"
exit 1
fi
test-windows:
name: Backend (windows)
runs-on: windows-latest
# The race-sensitive Windows package suite can exceed 15 minutes on a
# busy hosted runner; keep the cap finite while allowing normal completion.
timeout-minutes: 20
# Least-privilege: this job only reads source and runs tests, no PR/issue
# writes or release operations. Flagged by CodeQL workflow audit + matching
# CodeRabbit nitpick.
permissions:
contents: read
defaults:
run:
working-directory: apps/backend
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: apps/backend/go.mod
cache-dependency-path: apps/backend/go.sum
# Compile-checks every `*_windows.go` file (pty/conpty wrapper, job-object
# launcher, shell, signals, sysprocattr, vscode cleanup, …). The rest of
# the suite is build-tag-gated out automatically.
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
# Scope to packages with non-trivial Windows-specific code paths.
# Tests that genuinely cannot run on Windows (Unix sockets, POSIX
# symlinks under default Windows ACLs, delete-while-open) call t.Skip
# in source with a documented reason. The shell-fixture tests now use
# a self-hosting Go helper (see testmain_test.go) so they run on
# every platform.
- name: Test windows-sensitive packages
run: go test -race -v ./internal/agentctl/server/process/... ./internal/agent/runtime/agentctl/launcher/... ./internal/common/ptyexec/... ./internal/backendapp/ownershiplock/...
# Keep port-collision coverage targeted: the instance package contains
# broader stdio MCP fixture tests that depend on Unix command behavior.
- name: Test Windows port collision handling
shell: bash
run: |
go test -race -v ./internal/common/netutil -run '^TestIsAddrInUse'
go test -race -v ./internal/agentctl/server/instance -run '^TestAllocatePortAndListenerRetriesAddressInUse$'
go test -race -v ./internal/gateway/websocket -run '^TestResolveAndBindReportsOccupiedPort$'
# Keep this targeted: the service package has broad subprocess coverage,
# while this native test is the Windows path-semantics regression.
- name: Test Windows path semantics
run: >-
go test -race -v
-run '^(TestExplicitLocalRepositoryWindowsPathSemantics|TestListDirectory.*|TestDriveRootsFromMask|TestSplitAbsForRoot)$'
./internal/task/service