Files
kandev/.github/workflows/backend-tests.yml
T
2026-09-15 23:11:43 +01:00

780 lines
32 KiB
YAML

name: Backend Tests
# No trigger-level `paths:` filter, deliberately. A workflow skipped by one
# reports no conclusion at all, and a required check that never reports leaves
# a pull request unable to enter a merge queue and a merge group unable to
# leave it. `merge_group` cannot carry a `paths:` filter in the first place.
# The filter lives in the `changes` job below instead, and the `test` job
# reports the single conclusion the queue waits on. See docs/ci-merge-queue.md.
on:
push:
branches: [main]
pull_request:
branches: [main]
merge_group:
types: [checks_requested]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# Never cancel a merge-queue run. Its ref is unique per queue entry, so a
# cancellation here could only come from a mis-keyed group, and the queue
# would read the cancelled check as a failure and eject the pull request.
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
# 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
env:
# Container jobs may override HOME. Keep Corepack pointed at the pnpm cache
# prepared in the CI image.
COREPACK_HOME: /root/.cache/node/corepack
jobs:
runner_plan:
name: Plan external runner allocation
runs-on: ubuntu-latest
outputs:
plan: ${{ steps.plan.outputs.plan }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Plan runner allocation
id: plan
uses: ./.github/actions/plan-external-runners
with:
workflow: backend
run-id: ${{ github.run_id }}
families: >-
[{"name":"test","tier":"light"}]
burst: ${{ vars.KANDEV_CI_EXTERNAL_ENABLED }}
percent: ${{ vars.KANDEV_CI_EXTERNAL_PERCENT }}
light-label: ${{ vars.KANDEV_CI_RUNNER_LIGHT }}
standard-label: ${{ vars.KANDEV_CI_RUNNER_STANDARD }}
# The former trigger-level `paths:` list, applied one level down so a skip
# still produces a conclusion. `.github/scripts/changed-paths.py` implements
# the same last-match-wins semantics GitHub applies to `paths:`.
changes:
name: Detect relevant changes
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
run: ${{ steps.detect.outputs.run }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# The base commit has to be present locally for the diff below. Every
# other job in this workflow already pays for a full clone.
fetch-depth: 0
persist-credentials: false
- name: Detect relevant changes
id: detect
env:
PATTERNS: |
apps/backend/**
apps/web/lib/settings-discovery/contract.generated.json
apps/web/lib/settings-discovery/profile-contract.generated.json
scripts/settings-contract-ci.test.mjs
# 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
.github/scripts/changed-paths.py
.github/scripts/bounded-step-summary.py
!**/*.md
!**/*.mdx
!**/*.markdown
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
# The checkout is the merge ref, whose first parent is the base
# branch tip. Diffing against that tip directly collapses to a
# two-dot diff over everything main landed since the PR opened, so
# take the fork point instead.
pull_request) BASE="$(git merge-base HEAD "origin/${BASE_REF}")" ;;
# The queue branch is cut from this commit, so it is a true ancestor.
merge_group) BASE="${MERGE_GROUP_BASE_SHA}" ;;
push) BASE="${PUSH_BEFORE}" ;;
*) BASE="" ;;
esac
# Fail open. A first push to a branch, a force push, or an unknown
# event leaves no usable base, and running the suite needlessly is
# cheaper than merging something no job ever looked at.
if [[ -z "${BASE}" || "${BASE}" == "0000000000000000000000000000000000000000" ]] \
|| ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then
echo "No usable base commit for '${EVENT_NAME}'; running everything."
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# `-z`, not plain --name-only: git quotes non-ASCII paths under its
# default core.quotePath, and a quoted path matches no pattern, so a
# change to one would skip every job and still report success.
git diff --name-only -z "${BASE}" HEAD \
| python3 .github/scripts/changed-paths.py
static_checks:
name: Backend Static Checks
needs: changes
if: needs.changes.outputs.run == 'true'
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
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
- name: Verify settings catalog contract
working-directory: ${{ github.workspace }}
run: node --test scripts/settings-contract-ci.test.mjs
- name: Check required-store SQL portability
run: go run ./cmd/sqlguard ./internal
- name: Run catalog and SQLite conformance gates
run: |
set -euo pipefail
go test -v -race ./internal/persistence/storeconformance \
-run '^(TestStoreCatalogCompleteness|TestStoreConformance|TestUpgradeFixtureManifest|TestPreviousStableUpgrade)$'
# 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 }}
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
# On `pull_request` the checkout is the merge ref, whose first parent
# is the current base branch tip. `pull_request.base.sha` is the base
# tip from when the PR event fired, so it can be stale after main
# advances. Use the merge ref's actual base tip to keep the diff and
# incremental lint scoped to this PR.
#
# A merge_group run has no pull request behind it, so the queue branch
# is cut from `merge_group.base_sha`, which is the equivalent base.
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
BASE_SHA="$(git merge-base HEAD "origin/${BASE_REF}")"
elif [[ "${EVENT_NAME}" == "merge_group" ]]; then
BASE_SHA="${MERGE_GROUP_BASE_SHA}"
else
BASE_SHA="${PUSH_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)
needs: changes
if: needs.changes.outputs.run == 'true'
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
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
'
# Run the pinned reporter as a child process so its summary target can be
# redirected. A `uses:` step cannot override GITHUB_STEP_SUMMARY because
# the runner reapplies its own file-command environment to JS actions.
- name: Checkout Go test reporter
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
if: always()
with:
repository: robherley/go-test-action
ref: 2f859e0c8769d755d3174eecb9af8f64660827f3 # v1.1.0
path: .github/vendor/go-test-action
persist-credentials: false
- name: Generate test report
if: always()
shell: bash
working-directory: ${{ github.workspace }}
env:
BACKEND_TEST_SHARD: ${{ matrix.shard }}
run: |
report_summary="$RUNNER_TEMP/backend-test-summary-${BACKEND_TEST_SHARD}.md"
: > "$report_summary"
GITHUB_STEP_SUMMARY="$report_summary" \
INPUT_FROMJSONFILE="$GITHUB_WORKSPACE/apps/backend/test-results-${BACKEND_TEST_SHARD}.json" \
INPUT_MODULEDIRECTORY="$GITHUB_WORKSPACE/apps/backend" \
INPUT_OMIT="successful" \
node ".github/vendor/go-test-action/dist/index.js"
- name: Publish bounded test report summary
if: always()
shell: bash
working-directory: ${{ github.workspace }}
env:
BACKEND_TEST_SHARD: ${{ matrix.shard }}
run: |
python3 .github/scripts/bounded-step-summary.py \
--input "$RUNNER_TEMP/backend-test-summary-${BACKEND_TEST_SHARD}.md" \
--output "$GITHUB_STEP_SUMMARY" \
--diagnostics-label "backend-test-results-${BACKEND_TEST_SHARD}" \
--diagnostics-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID#artifacts"
- 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_ambient_env:
name: Backend (ambient integration env)
needs: changes
if: needs.changes.outputs.run == 'true'
# Checkout receives the short-lived job token. Keep this credential-bearing
# path on GitHub-hosted capacity during the external-runner pilot.
runs-on: ubuntu-latest
# test_shards runs in the same clean container and never sets these vars,
# so it cannot catch a package that starts reading them again — the class
# of bug that shipped alongside internal/gitlab's host-trust tests (they
# only passed because CI never set KANDEV_GITLAB_HOST/GITLAB_HOST/
# GITLAB_TOKEN), and that internal/github hit for real with an ambient
# GH_TOKEN/GITHUB_TOKEN picking a PAT client over the "no auth configured"
# path. This job poisons them with obviously-fake values so the GitLab-
# and GitHub-facing packages are forced to prove they stay hermetic
# instead of accidentally depending on an unset ambient env.
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-ambient-env-${{ runner.os }}-${{ hashFiles('apps/backend/go.mod', 'apps/backend/go.sum') }}
restore-keys: |
go-ambient-env-${{ runner.os }}-
go-${{ runner.os }}-
- name: Run GitLab-facing packages under a poisoned host environment
env:
KANDEV_GITLAB_HOST: https://gitlab.ambient-ci.invalid
GITLAB_HOST: https://gitlab.ambient-ci.invalid
GITLAB_TOKEN: glpat-ambient-ci-not-a-real-token
run: go test -race ./internal/gitlab/... ./internal/agentctl/server/process/...
- name: Run GitHub-facing package under a poisoned ambient environment
env:
GH_TOKEN: ghp-ambient-ci-not-a-real-token
KANDEV_MOCK_GITHUB: "true"
run: GITHUB_TOKEN=ghp-ambient-ci-not-a-real-token-legacy go test -race ./internal/github/...
# The required check. Runs on every event, including the ones where every job
# above is correctly skipped, so the merge queue always has a conclusion to
# read instead of a check that stays pending forever.
#
# `needs` covers every other job in this workflow on purpose. A merge queue
# gates on check names an admin selects by hand, and a job missing from this
# list is a job whose failure would never block a merge — silently, and
# without appearing anywhere in the ruleset. `postgres-boot` and
# `test-windows` were outside it before this workflow became a gate.
test:
name: Run Backend Tests
runs-on: ${{ fromJSON(needs.runner_plan.outputs.plan).test_runner }}
needs:
- changes
- runner_plan
- static_checks
- test_shards
- test_ambient_env
- postgres-boot
- postgres-18
- test-windows
if: ${{ always() }}
steps:
- name: Require backend checks to pass
env:
RUNNER_PLAN_RESULT: ${{ needs.runner_plan.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
STATIC_CHECKS_RESULT: ${{ needs.static_checks.result }}
TEST_SHARDS_RESULT: ${{ needs.test_shards.result }}
TEST_AMBIENT_ENV_RESULT: ${{ needs.test_ambient_env.result }}
POSTGRES_BOOT_RESULT: ${{ needs['postgres-boot'].result }}
POSTGRES_18_RESULT: ${{ needs['postgres-18'].result }}
TEST_WINDOWS_RESULT: ${{ needs['test-windows'].result }}
run: |
set -euo pipefail
echo "Runner plan: ${RUNNER_PLAN_RESULT}"
echo "Change detection: ${CHANGES_RESULT}"
echo "Static checks: ${STATIC_CHECKS_RESULT}"
echo "Test shards: ${TEST_SHARDS_RESULT}"
echo "Ambient env tests: ${TEST_AMBIENT_ENV_RESULT}"
echo "Postgres boot: ${POSTGRES_BOOT_RESULT}"
echo "Postgres 18: ${POSTGRES_18_RESULT}"
echo "Windows tests: ${TEST_WINDOWS_RESULT}"
# Runner planning must succeed because its outputs select the runner
# fleet. A planner error cannot silently become a hosted fallback.
if [[ "${RUNNER_PLAN_RESULT}" != "success" ]]; then
echo "Runner planning did not succeed; refusing to report a pass." >&2
exit 1
fi
# Change detection itself must succeed: if it errored we do not know
# whether the skips below were deliberate.
if [[ "${CHANGES_RESULT}" != "success" ]]; then
echo "Change detection did not succeed; refusing to report a pass." >&2
exit 1
fi
# `skipped` is a pass — the change-detection job decided nothing this
# workflow covers was touched. `cancelled` is not: a cancelled job
# never reached a verdict, and treating that as a pass would let an
# untested merge group through.
#
# This gate is the one check a human opens when a merge is blocked, so
# name the job that failed here rather than making them correlate the
# list above by eye.
for entry in \
"static_checks:${STATIC_CHECKS_RESULT}" \
"test_shards:${TEST_SHARDS_RESULT}" \
"test_ambient_env:${TEST_AMBIENT_ENV_RESULT}" \
"postgres-boot:${POSTGRES_BOOT_RESULT}" \
"postgres-18:${POSTGRES_18_RESULT}" \
"test-windows:${TEST_WINDOWS_RESULT}"; do
name="${entry%%:*}"
result="${entry#*:}"
if [[ "${result}" != "success" && "${result}" != "skipped" ]]; then
echo "${name} finished with result: ${result}" >&2
exit 1
fi
done
postgres-boot:
name: Backend Postgres
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
# The env-gated package suite runs with the race detector and can exceed
# ten minutes on a hosted runner as the repository test set grows.
timeout-minutes: 20
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 fixed PostgreSQL 16 persistence gates
run: |
set -euo pipefail
# Keep this package list explicit. PostgreSQL coverage is a reviewed
# contract, not a grep over test source markers that can silently
# omit a package when a test is renamed or moved.
POSTGRES_PACKAGES=(
./internal/agent/settings/store
./internal/analytics/repository/sqlite
./internal/automation
./internal/azuredevops
./internal/backendapp
./internal/db
./internal/delivery
./internal/github
./internal/gitlab
./internal/jira
./internal/linear
./internal/notifications/store
./internal/office/configsync
./internal/office/repository/sqlite
./internal/office/retention
./internal/orchestrator/messagequeue
./internal/persistence
./internal/persistence/storeconformance
./internal/plugins/state
./internal/prompts/store
./internal/runs/service
./internal/runtimeflags
./internal/secrets
./internal/sentry
./internal/system/settings
./internal/system/storage
./internal/task/repository/sqlite
./internal/telemetrycontract
./internal/terminal/repository
./internal/user/store
./internal/workflow/repository
./internal/workflowsync
./internal/worktree
)
go test -v -race "${POSTGRES_PACKAGES[@]}"
postgres-18:
name: Backend Postgres 18
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
timeout-minutes: 20
container: ghcr.io/kdlbs/kandev-ci:build-latest
services:
postgres:
image: ghcr.io/kdlbs/kandev-ci:postgres-18@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280
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 PostgreSQL 18 boot and upgrade gates
run: |
set -euo pipefail
output_file="$(mktemp)"
go test -v -race \
./internal/persistence/storeconformance ./internal/backendapp \
-run '^(TestPreviousStableUpgrade|TestPostgresBootInitializesRepositories)$' \
2>&1 | tee "${output_file}"
for test_name in \
'=== RUN TestPreviousStableUpgrade' \
'=== RUN TestPostgresBootInitializesRepositories'; do
if ! grep -q "^${test_name}$" "${output_file}"; then
echo "::error::PostgreSQL 18 gate matched no ${test_name#* } test"
exit 1
fi
done
test-windows:
name: Backend (windows)
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: windows-latest
# Dependency hydration, build, vet, and the race-sensitive package suite
# can consume 30 minutes on a cold hosted runner. Keep the cap finite while
# leaving the final targeted regression checks time to run.
timeout-minutes: 40
# 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
# The process package is large and the race detector is slow on
# windows-latest. Go's default package timeout is 10 minutes, which can
# terminate a healthy suite before the workflow's 30-minute job cap.
run: go test -race -v -timeout 25m ./internal/agentctl/server/process/... ./internal/agent/runtime/agentctl/launcher/... ./internal/common/ptyexec/... ./internal/backendapp/ownershiplock/... ./internal/system/storage/workspaces/...
- name: Test Windows managed runtime cache repair
run: go test -race -v ./internal/agent/managedruntime -run TestRemoveNpxExecutionTreeTreatsMissingWindowsChildAsAbsent
# 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
# Pure GOOS-branch logic (no syscalls), but it must actually observe
# Windows to prove the unavailable branch, not just compile under it.
- name: Test Windows agent-survival flag availability
run: go test -race -v -run '^TestAgentSurvivalAvailabilityMatchesPlatformScope$' ./internal/runtimeflags