Skip to content

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.work and every module's go.mod — drift gate in tests/workspace/goworkdrift_test.go (TestGoVersionFileMatchesEveryModule).
  • GitHub Actions CI — .github/workflows/ci.yaml passes go-version-file: .go-version to actions/setup-go.
  • The CI drift gate in tests/workspace/ci_workflow_test.go (TestCIWorkflow_EveryJobPinsGoVersion) accepts either that dotfile reference or the literal version matching requiredGoVersion — 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:

  1. Edit /.go-version — change the single line to the new version.
  2. Edit the requiredGoVersion constant in tests/workspace/goworkdrift_test.go.
  3. Run make test — the drift gates fail on every go.mod / go.work that is out of sync; fix each one reported in the output.
  4. If the bump crosses a major language feature, check internal/platform/go.mod for 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.2

The 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 — every uses: pin in the workflows under .github/workflows/.
  • gomod — every Go module listed in go.work. The drift gate in tests/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 --ignore entry — the --ignore flag 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.9); go.mod / go.work declare only 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.

The gate compares the major.minor of each directive, so both go 1.26 and go 1.26.0 satisfy a .go-version of 1.26.x; a go 1.27.x directive still fails. Both spellings appear in the tree on purpose. Go orders 1.26 below 1.26.0, so a module that depends on an upstream declaring go 1.26.0 — the Kubernetes 0.36 family, sigs.k8s.io/controller-runtime 0.24, letsencrypt/boulder — has its own directive raised to go 1.26.0 by go mod tidy, and go.work carries go 1.26.0 so the workspace can load those modules at all. A module whose graph demands nothing newer keeps the bare go 1.26; the two spellings are equivalent as a language floor, and any 1.26.x toolchain satisfies both. Do not "normalise" the directives by hand in either direction — make tidy is the only writer, and it reinstates whatever the dependency graph requires. What the gate protects is that every module sits on the same Go minor line as the pinned toolchain, and that is unaffected by the patch suffix.

Why the scan pins its own toolchain ​

govulncheck resolves the standard library it audits from the toolchain in effect at scan time — not from the toolchain that built the govulncheck binary. An unpinned scan therefore grades whichever go sits on the operator's PATH, and the resulting failure is asymmetric: CI installs the .go-version release exactly (through actions/setup-go's go-version-file input) and reports a clean scan, while a contributor on an older patch release sees that release's already-fixed stdlib advisories reported as called vulnerabilities and watches make vuln exit non-zero on an unmodified tree. Read literally, that failure says "a dependency is vulnerable", which is false, and it invites a dependency bump that fixes nothing.

make vuln closes the gap by running each scan under GOTOOLCHAIN=$(GO_TOOLCHAIN), where GO_TOOLCHAIN is go prefixed to the .go-version contents. The pinned toolchain is fetched from the module proxy on first use, so the target reproduces the CI verdict on any machine regardless of the installed Go. Note that exporting GOTOOLCHAIN=auto does not achieve this: auto only upgrades when a go.mod demands a newer release than the local one, and every module here declares a floor of go 1.26 or go 1.26.0, which any older 1.26.x already satisfies. The pin is asserted by tests/workspace/vuln_toolchain_pin_test.go.

Advisories without a fixed version ​

Some advisories carry Fixed in: N/A — they flag a package as unmaintained rather than describing a patchable defect. GO-2026-5932 (golang.org/x/crypto/openpgp) is the one this repository currently surfaces. It appears only under govulncheck's Module Results heading, because the module golang.org/x/crypto is in the require graph; the repository imports acme and argon2 from that module and never openpgp. No dependency bump can retire the finding, no code calls the flagged package, and govulncheck exits 0, so the vuln gate stays green. Module-level findings are informational — only the Your code is affected by N vulnerabilities line drives the exit code.

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/health fans 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/server Serve 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 the no-default-http-client and no-cross-context-imports rules that the DDD layout depends on), plus errcheck, gocritic, gosec, govet, revive, staticcheck.

  • Quality-of-life tier — adds:

    LinterWhat it catches
    errorlintbare err == x / %w omissions
    bodycloseHTTP response body leaks
    contextcheck / noctxcontext.Context not threaded through
    sloglintslog 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 := x shadows under Go 1.22+ semantics
    exhaustivenon-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 actionlint runs actionlint over .github/workflows/*.yml, catching invalid ${{ … }} expressions, shell quoting bugs in run: blocks, misspelled inputs, and missing permissions. actionlint is a Go tool, so the Makefile go installs it the same way govulncheck is installed — pinned by ACTIONLINT_VERSION.

  • make hadolint runs hadolint over the repo-root Dockerfile. hadolint is a Haskell binary; the Makefile prefers a local install and transparently falls back to the official hadolint/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.1

The 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 tags, the licence tag naming 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 by actions/setup-go as 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.

REUSE compliance ​

The repository follows the REUSE specification 3.3, so every tracked file carries machine-readable licensing, not only the file types the SPDX gate covers:

  • LICENSE at the repository root holds the Business Source License 1.1 with the project's parameters. LICENSES/ holds one text per licence the tree uses: BUSL-1.1.txt (a byte-identical copy of LICENSE, pinned by TestLicenseFiles in tests/workspace/license_files_test.go), Apache-2.0.txt and MIT.txt.
  • REUSE.toml at the repository root licenses every file without an SPDX header of its own (TypeScript, Markdown, JSON, SQL, lockfiles, golden files) as BUSL-1.1 by plexsphere contributors, and overrides the two vendored third-party files: the ReDoc bundle (MIT) and the external-secrets CRD bundle (Apache-2.0). A new vendored file gets its own override table there. A file's own header still wins over the catch-all, which is why the SPDX gate above keeps enforcing headers on source files.
  • REUSE reads an SPDX tag anywhere in a file, including string literals and prose. Where a file carries tag text as data (the Makefile recipes that stamp headers onto generated code, the fixtures in spdx_headers_test.go), the region sits between REUSE-IgnoreStart and REUSE-IgnoreEnd comment lines, placed between Makefile rules and never inside a recipe. In prose, name a tag without a trailing colon, as this page does; a tag followed by a colon makes REUSE parse the rest of the line as a licence expression.
  • make reuse-lint runs reuse lint from the fsfe/reuse image pinned by REUSE_VERSION, with a local reuse binary as the fallback when docker is missing. The reuse CI job runs the same target, and nonGoCIJobs exempts it from the Go pin. To bump the tool, raise REUSE_VERSION and re-run make reuse-lint.

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.

Both image Dockerfiles build for a platform other than the builder's without an emulator. Each build stage runs on the builder's own platform (FROM --platform=$BUILDPLATFORM): the Go stage cross-compiles with GOARCH=${TARGETARCH}, and the console stage bundles static files that are the same for every target. The runtime stages only copy. make release relies on this to build linux/amd64 and linux/arm64 images on one runner, and TestDockerfilesCrossCompileForTheTargetPlatform in tests/workspace/dockerfile_test.go pins it. A single-platform --load build, which every dev and e2e path uses, still targets the daemon's own platform.

Two runtime flavours, selected by COMPONENT ​

Six of the seven components are pure Go. They compile with CGO_ENABLED=0 and ship on gcr.io/distroless/static-debian12:nonroot.

plexsphere-signer is the exception. Its PKCS#11 key provider opens a vendor module with dlopen at runtime, and the modules it targets (SoftHSM, AWS CloudHSM, Thales Luna) are glibc shared libraries. A musl-linked binary cannot load one, and the static base ships no dynamic loader at all. The signer therefore compiles with CGO_ENABLED=1 on golang:<go-version>-bookworm, with -tags pkcs11,aws_kms,gcp_kms, and ships on gcr.io/distroless/cc-debian12:nonroot, which adds glibc, libgcc, libstdc++ and libssl. An explicit --build-arg GOTAGS=… composes onto the three provider tags rather than replacing them.

The COMPONENT build-arg alone picks the flavour. The Dockerfile declares one base-<component> alias stage per accepted component and resolves FROM base-${COMPONENT:-plexsphere} AS runtime against them, so none of the twelve sites that build the signer image (two Makefile recipes and ten kind-load.sh scripts) passes a second argument. A COMPONENT value with no alias stage fails the build before the compile step.

The :-plexsphere fallback is load-bearing. BuildKit resolves a FROM at parse time, so a bare base-${COMPONENT} aborts with failed to parse stage name "base-" when the build-arg is omitted, before the build stage's test -n "${COMPONENT}" guard can print the list of accepted components. The fallback cannot select the wrong binary, because the runtime stage copies from the build stage: whenever the fallback is the value in use, the guard has already exited 64.

A cross build installs the matching Debian cross compiler (gcc-aarch64-linux-gnu or gcc-x86-64-linux-gnu) and points CC at it, so the linux/amd64 and linux/arm64 signer images still build on one runner without an emulator. The build stage then asserts the result with go version -m, which reads a cross-compiled binary's build settings without executing it.

TestDockerfileRuntimeBaseFollowsTheComponent and TestDockerfileBuildsTheSignerWithCgoAndKeyProviderTags in tests/workspace/dockerfile_test.go pin the alias stages and the compile flags.

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 — write go mod tidy across every workspace module. Use this locally after a dependency change.
  • make tidy-check — assert go mod tidy would not rewrite anything, without mutating the working tree. This is what the tidy CI job runs; a failure prints a unified diff pointing at the offending go.mod / go.sum so 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.
  • ./toolbox.md — the toolbox image that carries these tools on a contributor machine, and the TOOLBOX_*_VERSION pins it adds.
  • ../../CLAUDE.md — the Toolchain section pinning the Go floor and the golangci-lint install recipe.