Appearance
Toolchain pins and upgrade procedure
This document records where each build-time dependency is pinned, why each pin exists, and how to move it. Every pin is backed by a drift gate under tests/workspace/, so an inconsistent upgrade fails CI before it reaches main.
Go
The Go toolchain version is recorded once, in the repo-root .go-version dotfile. Every consumer reads that file:
go.workand every module'sgo.mod— drift gate intests/workspace/goworkdrift_test.go(TestGoVersionFileMatchesEveryModule).- GitHub Actions CI —
.github/workflows/ci.yamlpassesgo-version-file: .go-versiontoactions/setup-go. - The CI drift gate in
tests/workspace/ci_workflow_test.go(TestCIWorkflow_EveryJobPinsGoVersion) accepts either that dotfile reference or the literal version matchingrequiredGoVersion— the literal form exists so a one-off matrix cell can still pin a different toolchain without tripping the gate.
The pinned string inside the dotfile is asserted by TestGoVersionFileIsPinnedToRequired; changing the constant in that test and the dotfile is therefore the whole ritual for a version bump:
- Edit
/.go-version— change the single line to the new version. - Edit the
requiredGoVersionconstant intests/workspace/goworkdrift_test.go. - Run
make test— the drift gates fail on everygo.mod/go.workthat is out of sync; fix each one reported in the output. - If the bump crosses a major language feature, check
internal/platform/go.modfor dependencies that need to move in lockstep (Prometheus, chi).
golangci-lint
The lint binary has its own floor because it needs to be built with a Go toolchain at least as recent as our pinned version. The operational detail lives in Makefile's LINT_VERSION_FLOOR note and in .golangci.yml under the run.go knob — CI therefore installs the tool from source via go install, using the repo's Go toolchain. See the comments at the call site for the replacement criterion (switch back to a released binary as soon as an official release ships built with the pinned version).
GitHub Actions
Every third-party uses: reference in .github/workflows/ pins a 40-hex commit SHA, with the human-readable version tag kept as a trailing comment:
yaml
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2The drift gate in tests/workspace/ci_workflow_test.go (TestCIWorkflow_ThirdPartyActionsArePinnedBySHA) blocks a tag-only pin from landing. The rationale is straightforward: tags are mutable on GitHub's side, and a tag rewrite has been used in past supply-chain incidents to smuggle malicious action code past CI. Pinning the SHA takes that vector off the table.
First-party prefixes (actions/…, github/…) are allow-listed in the test because GitHub controls those tag servers; the gate still accepts SHAs there if you prefer the explicit form.
The automation angle: Dependabot keeps the SHAs current by rewriting both the SHA and the # <tag> comment in the same PR. Upgrading the pin is therefore still a one-line Dependabot merge — you do not take on manual tag-lookup work to get the security benefit.
Dependency updates
.github/dependabot.yml opens weekly bump PRs for three ecosystems:
github-actions— everyuses:pin in the workflows under.github/workflows/.gomod— every Go module listed ingo.work. The drift gate intests/workspace/dependabot_test.go(TestDependabotGomodListsEveryWorkspaceModule) keeps the configured directory list in sync with disk: add a new bounded context, and the test fails until Dependabot is wired to its module root. Minor + patch bumps group into a single weekly PR; majors open individually.docker— the FROM pins in the repo-root Dockerfile(s).
Major bumps are treated the same as hand-authored changes: the PR still needs a human review, the matching drift gate still runs, and CLAUDE.md's tests-and-docs-from-the-start rule applies when a major forces a migration.
Vulnerability scanning
make vuln installs and runs govulncheck across every workspace module. The tool walks the actual symbol call graph, so it only reports CVEs the repository's code (or its dependencies' transitively-called symbols) can actually reach — a meaningful signal rather than a raw module-level match.
The CI job named vuln runs the same target on every PR; any new advisory that lands while a PR is open will light this gate up. The gate also covers Go standard-library CVEs, which means a stdlib advisory forces either:
- a patch-level bump of
.go-version(most common), or - documented acceptance via a
govulncheck --ignoreentry — the--ignoreflag is deliberately not wired today because there are no accepted risks to record yet.
.go-version holds the exact toolchain patch version (e.g. 1.26.3); every go.mod / go.work still declares only the major.minor (go 1.26) as the language floor. The drift gate in tests/workspace/goworkdrift_test.go (TestGoVersionFileIsPinnedToRequired and TestGoVersionFileMatchesEveryModule) enforces that split: a patch bump to fix a CVE is a one-line edit to .go-version; a major.minor bump touches every module's go directive in lockstep.
Local contributors whose installed Go is older than the one in .go-version should either install the matching release or export GOTOOLCHAIN=auto so the Go toolchain transparently downloads the required patch for commands run inside the workspace.
Race detector
make test-race invokes go test -race across every workspace module and is wired as the race CI job. The race detector is treated as a first-class gate — not an optional sweep — because several subsystems already ship with concurrency the naive implementation would get wrong:
internal/platform/healthfans readiness probes out through per-probe goroutines and aggregates their results — any shared mutable state between probes is a race the gate catches immediately.internal/platform/serverServe and the graceful-shutdown path run concurrently; deadline handling and listener closing are exactly the edge where a race hides.- The integration harness under
tests/integration/runs both binaries in parallel on ephemeral ports, which amplifies any shared bootstrap state.
A race-flagged failure is never a flake: the detector is deterministic on the path it actually executes. Reproduce locally with make test-race before re-running CI.
Static analysis (golangci-lint)
/.golangci.yml enables 25 linters in two tiers:
Architecture / correctness core —
depguard(enforces theno-default-http-clientandno-cross-context-importsrules that the DDD layout depends on), pluserrcheck,gocritic,gosec,govet,revive,staticcheck.Quality-of-life tier — adds:
Linter What it catches errorlintbare err == x/%womissionsbodycloseHTTP response body leaks contextcheck/noctxcontext.Contextnot threaded throughsloglintslog attribute style drift paralleltest/tparallel/thelper/testifylinttest discipline misspelleveryday typos in docs + code perfsprint/prealloc/wastedassignhot-path hygiene unparam/unused/nilerrdead code + error-return bugs copyloopvarredundant x := xshadows under Go 1.22+ semanticsexhaustivenon-exhaustive switch over typed enums
When a linter fires, fix the finding rather than silencing the linter; per-file or per-line //nolint:<name> // <reason> comments are accepted only with a concrete reason the reviewer can verify.
Workflow and Dockerfile linters
Two supplementary linters cover the CI plumbing and container image definitions that golangci-lint does not see:
make actionlintruns actionlint over.github/workflows/*.yml, catching invalid${{ … }}expressions, shell quoting bugs inrun:blocks, misspelled inputs, and missing permissions. actionlint is a Go tool, so the Makefilego installs it the same waygovulncheckis installed — pinned byACTIONLINT_VERSION.make hadolintruns hadolint over the repo-rootDockerfile. hadolint is a Haskell binary; the Makefile prefers a local install and transparently falls back to the officialhadolint/hadolint:${HADOLINT_VERSION}Docker image so contributors on fresh laptops do not hit a missing-binary wall.
Both ship as dedicated CI jobs (actionlint, hadolint), wired through the requiredCIJobs map in tests/workspace/ci_workflow_test.go. The Go-version drift gate carries a nonGoCIJobs exemption for hadolint because that job never needs a Go toolchain on the runner.
License metadata (SPDX)
Every tracked source file that supports line comments carries a REUSE-style header pair near its top:
go
// SPDX-FileCopyrightText: Copyright 2026 plexsphere contributors
// SPDX-License-Identifier: BUSL-1.1The comment prefix follows the file type (// for Go, go.mod, go.work; # for YAML, Makefile, Dockerfile, shell, CODEOWNERS, .gitignore). Files with a shebang keep it on line 1; the SPDX pair goes on lines 2–3.
The drift gate in tests/workspace/spdx_headers_test.go (TestSPDXHeadersPresent) walks the repo and asserts every covered file matches both SPDX-FileCopyrightText: and SPDX-License-Identifier: BUSL-1.1 within the first 40 lines — a generous window that leaves room for package doc comments and build tags before the header.
Two files are legitimately exempt and listed in the test's spdxAllowlist:
go.sum— comment lines are rejected by the module layer..go-version— consumed byactions/setup-goas a single raw version string; a#-prefixed line would be interpreted as a literal version lookup.
A new bounded context, a new workflow YAML, or a new shell script all trip this test until the header is added. The header never rides on auto-generated files (vendor trees, .planwerk/ JSON state), which is why the walker skips those directories outright.
Container images
A single Dockerfile builds both binaries. The binary selection is a build-arg so the file stays honest against drift:
bash
docker buildx build --file Dockerfile \
--build-arg COMPONENT=plexsphere --build-arg PORT=8080 \
--tag plexsphere:dev .
docker buildx build --file Dockerfile \
--build-arg COMPONENT=plexsphere-signer --build-arg PORT=8081 \
--tag plexsphere-signer:dev .The earlier split into Dockerfile.plexsphere / Dockerfile.signer duplicated the long COPY internal/*/go.mod block for every bounded context and produced real drift (the two files diverged in practice every time a new context was added). The merged form resolves the selected binary under a fixed /entrypoint path inside the image, which keeps ENTRYPOINT as a literal exec-form array — distroless has no shell to expand ${VAR} at container start.
The drift gate in tests/workspace/dockerfile_test.go (TestDockerfileIsSingleFile) blocks a re-split by asserting exactly one Dockerfile and no Dockerfile.<variant> companions in the repo root. The companion test (TestKindLoadScriptBuildsEveryComponent) asserts tests/e2e/bootstrap/kind-load.sh still builds every COMPONENT the Dockerfile supports — renaming a binary without touching the script would otherwise silently drop its image from the e2e kind cluster.
Module hygiene
go mod tidy drift is a common source of stale go.sum entries and phantom indirect dependencies. The repo ships two companion Make targets plus a CI job:
make tidy— writego mod tidyacross every workspace module. Use this locally after a dependency change.make tidy-check— assertgo mod tidywould not rewrite anything, without mutating the working tree. This is what thetidyCI job runs; a failure prints a unified diff pointing at the offendinggo.mod/go.sumso you can see exactly what would change.
The contract is symmetric with the other drift gates: a new dependency requires make tidy before committing; omitting that step blocks the PR on the tidy job. The two targets iterate the module set via go list -m so they stay in lockstep with go.work without needing a separate list of directories.
Rule of thumb
- A version string that appears in more than one file must have a drift gate that ties them together. Prefer a single machine-readable file (
.go-version,PLEXD_VERSION, …) consumed by every tool. - If you invent a new pin, add the gate in the same pull request. CLAUDE.md's "tests and documentation from the start" rule applies here more than anywhere else — an undocumented pin with no gate drifts on its first upgrade.
Cross-references
./layout.md— the bounded-context module map and the depguard rules the toolchain enforces../ci.md— the CI pipeline that installs and runs these pinned tools.../../CLAUDE.md— the Toolchain section pinning the Go floor and the golangci-lint install recipe.