Skip to content

CI Workflow

The .github/workflows/ci.yml workflow runs lint checks, unit tests, and integration tests on every pull request. All jobs run in parallel with no inter-job dependencies. lint and integration-test run on ubuntu-latest; unit-test runs on Linux, Windows and macOS.

Trigger Events

EventFilterDescription
pull_request(all)Runs on opened, synchronized, and reopened PRs
workflow_dispatchManual run against any branch

The workflow does not run on pushes to main. A pull_request run checks out the merge result rather than the branch head, so the commit that lands on main is the commit CI already tested; repeating the run after the merge tests it a second time. Use workflow_dispatch to verify main on demand — after a merge whose base had moved since the PR run, for example.

A branch without an open PR does not trigger the workflow on push.

Jobs

All jobs use actions/checkout@v4 and actions/setup-go@v5 with Go 1.26, pinned to full SHA hashes for supply-chain hardening. Each job sets timeout-minutes to prevent runaway CI from consuming unlimited minutes. Module caching is handled automatically by setup-go@v5 using go.sum as the cache key.

lint

Static analysis and dependency verification.

StepCommand / ActionPurpose
Checkoutactions/checkout@v4Clone repository
Setup Goactions/setup-go@v5 (go-version: '1.26')Install Go with module caching
Verify dependenciesgo mod verifyValidate checksums against go.sum
Run go vetgo vet ./...Built-in static analysis
Install staticcheckgo install honnef.co/go/tools/cmd/staticcheck@latestInstall advanced static analyzer
Run staticcheckstaticcheck ./...Detect bugs and deprecated patterns
Run golangci-lintgolangci/golangci-lint-action@v9 (version: v2.12.2)Aggregated linter suite

The golangci-lint action runs golangci-lint v2 (pinned via the version input), configured by .golangci.yml in the repository root. The config restores the default exclusions that golangci-lint v1 applied implicitly, since v2 dropped them. golangci-lint v2 is required because v1 binaries are built with Go 1.24 and refuse to run against the go 1.26.0 directive in go.mod.

unit-test

Runs all tests with race detection and cache disabled, once per platform.

StepCommand / ActionPurpose
Checkoutactions/checkout@v4Clone repository
Setup Goactions/setup-go@v5 (go-version: '1.26')Install Go with module caching
Run unit testsgo test -race -count=1 ./...Execute all tests, detect data races
Run privileged macOS testssudo env GOMODCACHE=… GOCACHE=… PLEXD_TEST_REAL_PF=1 "$(command -v go)" test -race -count=1 -v -run '^(TestDarwinController_RealUTUN|TestDarwinRouteController_Real|TestPFController_Real|TestWGControllers_RealUTUN)$' ./internal/wireguard/ ./internal/bridge/ ./internal/policy/ (macOS only)Create a real utun, add a route and toggle forwarding, load a pf anchor, and drive the bridge access and site-to-site controllers over real utuns, as root, verifying each and its cleanup
Run real Wintun testsgo test -race -count=1 -v -run '^(TestWindowsController_RealWintun|TestWGControllers_RealWintun)$' ./internal/wireguard/ ./internal/bridge/ (Windows only, with PLEXD_TEST_REAL_WINTUN=1)Create a real Wintun adapter and verify its address, MTU, UAPI pipe and cleanup, and drive the bridge access and site-to-site controllers over a real adapter
Run real route testsgo test -race -count=1 -v -run '^TestWindowsRouteController_Real$' ./internal/bridge/ (Windows only, with PLEXD_TEST_REAL_ROUTES=1)Add a route and toggle forwarding on the runner's adapter and verify their cleanup
Run real WFP testsgo test -race -count=1 -v -run '^TestWFPController_Real(NAT)?$' ./internal/policy/ (Windows only, with PLEXD_TEST_REAL_WFP=1)Install WFP filters and a NetNat object on the runner and verify their cleanup

The -count=1 flag disables test caching to ensure every CI run exercises all tests. The -race flag enables the Go race detector.

The privileged step is gated on if: runner.os == 'macOS' and runs only there. TestPFController_Real additionally asks for PLEXD_TEST_REAL_PF, which the step sets: root alone would let sudo go test ./... on a developer's Mac enable pf on their host and load a real anchor. Creating a utun device needs root, and so do altering the routing table and the forwarding sysctl, so the four macOS tests against the real kernel skip in the unprivileged run and would otherwise never execute. TestWGControllers_RealUTUN creates its utuns through the bridge controllers and routes over them. sudo resets the environment, so the step passes the module and build caches through sudo env and resolves the toolchain with $(command -v go) before sudo replaces PATH; root then reuses what the previous step downloaded and built. -v is what makes a skip visible in the log rather than passing as a bare ok.

The Wintun step is the Windows counterpart, gated on if: runner.os == 'Windows'. It needs no sudo: that runner is already elevated, which is why the gate of its two tests, TestWindowsController_RealWintun and TestWGControllers_RealWintun, is PLEXD_TEST_REAL_WINTUN rather than a privilege check. Without a separate variable both would also run inside Run unit tests and create real adapters there. They write the embedded wintun.dll beside their own binaries, so the step fetches nothing, and -v makes a skip visible here too.

The route step is gated the same way, on PLEXD_TEST_REAL_ROUTES, and for the same reason: without its own variable the test would alter the runner's routing table from inside Run unit tests. The WFP step carries PLEXD_TEST_REAL_WFP for the same reason: without its own variable its two tests would install filters and a NetNat object on the runner from inside Run unit tests.

Matrix strategy: the job sets runs-on: ${{ matrix.os }} over three labels, producing the jobs unit-test (ubuntu-latest), unit-test (windows-latest) and unit-test (macos-latest).

LabelImageArchitecture
ubuntu-latestUbuntux64
windows-latestWindows Server 2025x64
macos-latestmacOS 26arm64

fail-fast: false keeps a Windows failure from cancelling the Linux and macOS jobs, whose results are what tell you whether a failure is platform-specific. timeout-minutes is 30 rather than the usual 10: the Linux job takes about a minute and a half, and no measured Windows duration exists yet.

-race needs cgo everywhere except macOS, so the Windows job links against the C toolchain its runner image ships. The race detector supports windows/amd64 and both macOS architectures, which is what these three labels resolve to.

Tests that cannot run on a platform skip themselves rather than being excluded: see Tests that cannot run everywhere.

integration-test

Runs only test functions matching the Integration pattern.

StepCommand / ActionPurpose
Checkoutactions/checkout@v4Clone repository
Setup Goactions/setup-go@v5 (go-version: '1.26')Install Go with module caching
Run integration testsgo test -race -count=1 -run Integration ./...Execute integration tests with race detection

The -run Integration flag performs substring matching, selecting test functions such as TestIntegration_*, TestRelayIntegration_*, TestBridgeReconcileIntegration_*, and TestUserAccessIntegration_*. Packages with no matching tests are skipped gracefully.

Go Version

All jobs pin Go 1.26 via go-version: '1.26' (not 1.26.0), which resolves to the latest patch release. This matches the version specified in go.mod.

Module Caching

actions/setup-go@v5 automatically caches downloaded Go modules using go.sum as the cache key. No explicit cache configuration is needed. On cache hit, go mod download is skipped, reducing job duration.

Adding a New Job

  1. Add a new entry under jobs: in .github/workflows/ci.yml
  2. Set runs-on: ubuntu-latest, or an os matrix with runs-on taken from it, when the job has to run on every platform, as unit-test does
  3. Set timeout-minutes to an appropriate value (10 for standard jobs, 15 for integration tests, 30 for the cross-platform unit-test matrix)
  4. Include actions/checkout and actions/setup-go pinned to full SHA hashes as the first two steps
  5. Do not add a needs: key unless the job genuinely depends on another job's output
  6. Add the job's run commands as subsequent steps

Action Versions

All actions are pinned to full SHA hashes for supply-chain hardening. The version comment after each SHA indicates the corresponding release tag.

ActionVersionSHAPurpose
actions/checkoutv4.3.134e114876b0b11c390a56381ad16ebd13914f8d5Repository checkout
actions/setup-gov5.6.040f1582b2485089dde7abd97c1529aa768e1baffGo installation and module cache
golangci/golangci-lint-actionv9.2.182606bf257cbaff209d206a39f5134f0cfbfd2eegolangci-lint installation and run