Development

How to build, test and extend myriarch: toolchain, repository layout, build tags, the local gates, the web UI, end-to-end tests and the documentation tooling.

This guide is for people who change the code, the web UI, the scripts or the documentation. To run the system once, read Local development first. Contribution rules (sign-off, pull requests, AI-assisted work) are in CONTRIBUTING.md.

Repository layout

api/openapi.yaml          REST contract (OpenAPI 3.1)
assets/brand/             logo files and the brand guide
cmd/                      myriarchd, myrctl, myriarch-updater, myriarch-sim
internal/                 all Go packages (see below)
web/                      web UI: React, TypeScript, Vite
deploy/                   Helm chart, Docker Compose, Kubernetes manifests, installers, systemd units, supervisor configs
examples/quickstart/      a complete example GitOps repository
examples/sim/             simulator profiles and the component list
gitops-template/          template for your own GitOps repository (with CI)
hack/kind/                Kubernetes probe on a local kind cluster
scripts/                  end-to-end tests, image build scripts, the language guard
ci/                       gate scripts, pinned tool versions (ci/versions.env), script tests
contrib/                  optional operator tools (for example the offline mirror helpers)
docs/                     documentation, design contracts and decision records
website/                  project website (Hugo + Docsy): sources, checks and the build of myriarch.dev

The Go packages under internal/ fall into these groups:

GroupPackages
Domain model and interfacesdomain, store, eventbus, leader, control, executor, notify, authz, api, audit, config
Server surfacesopamp, authn, httpserver, coreapi, fleetapi, live, metrics, webui (the embedded UI, build tag embedui)
Persistence and high availabilitystore/postgres, eventbus/pgbus, leader/pglock, housekeeping (retention jobs on the leader)
GitOps, rendering and rolloutsgitops, blueprint, assignment, rollout, executor/*, release, artifact, install (the plan for a host installed from the server), myrctl
Host agent and simulatorupdater, sim
Cross-cutting helpersbrand (all product identifiers), configload, netguard, secretfile, version, lru, actorname
Test supporttestutil (embedded PostgreSQL helper), testdata

Product identifiers (binary names, cookie names, token prefixes, attribute keys) are defined once in internal/brand.

Toolchain

ToolVersion
Gothe version in go.mod; newer releases work, CI uses exactly that version
Node.jsthe LTS release in .node-version (CI uses exactly that version); at least 22.22.2 or 24.15 (engines in web/package.json; the website needs 22 or newer)
pnpmthe version in the packageManager field of web/package.json and website/package.json (corepack enable provides it)
make, curl, jq, python3, gitany recent version

No container runtime is needed to build, test or run the system locally (see ADR-0011). Only the optional Kubernetes probe needs Docker.

Build tags

TagEffect
embeduiEmbeds the built web UI (internal/webui/dist) into myriarchd. Without it, myriarchd serves a placeholder page.
devpgLinks the embedded PostgreSQL (internal/devpg, based on github.com/fergusstrange/embedded-postgres) so that myriarchd dev can start its own database.

The Makefile builds the tag lists as comma-separated values:

VariableValueUsed by
UI_TAGSembedui if internal/webui/dist/index.html exists, otherwise emptybuilding block of the others
DEV_TAGSUI_TAGS + devpgmake build, make dev, make e2e (through build)
RELEASE_TAGSUI_TAGSmake release

Further places:

  • make test and make test-race run go test ./... without tags (this covers the devpg stub) and additionally go test -tags devpg ./internal/devpg/... (the embedded PostgreSQL implementation).
  • scripts/build-image-crane.sh (make image-crane) builds myriarchd with embedui,devpg by default (IMAGE_VARIANT=dev). This is the dev and simulator image, and myriarchd dev runs there. IMAGE_VARIANT=release builds myriarchd with embedui only and leaves out /opt/myriarch/release-fixtures, so the image holds no OpenTelemetry release fixtures. Both variants contain LICENSE and THIRD_PARTY_NOTICES under /usr/share/licenses/myriarch/. The subcommand scripts/build-image-crane.sh layout prints the archive paths of the data and license entries of the chosen variant without a registry, network or build. The crane scripts need IMAGE_REPO (there is no default registry) and respect DOCKER_CONFIG without setting it. These builds run as user and group 65532 and never push to ghcr.io/myriarch/*. The published images are built from the release archives instead: make release-snapshot, then ALLOW_DIRTY=1 IMAGE_SOURCE=dist IMAGE_KIND=server scripts/build-image-crane.sh (and IMAGE_KIND=updater) writes the OCI layouts and SBOMs below .cache/image-dist/; ci/release/push-images.sh pushes a layout by digest. The server image holds myriarchd, myrctl, myriarch-sim, the example repositories and the data directory /var/lib/myriarchd (owned by 65532:65532); all of it is read from the release archives. The image contract, the Dockerfile build contexts and the image license paths are described in deploy/README.md.
  • deploy/release/goreleaser.yaml builds myriarchd with embedui only.

Release builds omit devpg

Release binaries must not link embedded-postgres or its dependency github.com/xi2/xz. embedded-postgres is MIT licensed, but xi2/xz is public domain (not on the license allowlist), and the PostgreSQL binaries are downloaded at runtime (see below), which is not wanted in a release artifact. ci/check-release-deps.sh verifies this with go list -deps for linux, windows and darwin and fails if one of the forbidden modules is linked. The same script checks that the UI bundle contains no font files and that web/package.json has no @fontsource dependency (the UI uses system font stacks). make check-local runs it as part of the go gate, and the aggregate CI job ci-ok, which the release guard requires on the tagged commit, covers it, so a released commit has passed it. The release build itself (ci/build-release.sh, goreleaser) does not run it again; the release workflow has to run it, ci/check-release-licenses.sh and scripts/gen-third-party-notices.sh --check before it packages (deploy/release/README.md, section on the release workflow).

License check of the release builds

ci/check-release-licenses.sh runs go-licenses (pinned in ci/versions.env, installed with ci/install-tools.sh go-licenses) over the release builds of all four commands for linux, windows and darwin, without the devpg tag. It fails when a linked Go package has a license outside the allowlist: the entries of the list release in ci/license-allowlist.txt (Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, MIT, Zlib). The same file lists the rest of the CNCF allowlist under review, which only the dependency review of a pull request allows; widening the list release is a decision record (ADR-0038). go-licenses reads Go packages: the check does not see the npm packages that the web UI bundle carries into myriarchd. No list is applied to them when a release is built, and the dependency review is their only license check (tslib, under 0BSD, a license of the list review, ships in the bundle today). make check-local runs the check as part of the go gate. Documented exceptions live in ci/license-overrides.txt, which the notices generator reads too (see Licenses, headers and third-party notices); the license that an entry declares must be on the list release. The entries are:

  • github.com/in-toto/attestation, github.com/in-toto/in-toto-golang and github.com/cyberphone/json-canonicalization: go-licenses cannot classify their LICENSE files; all three contain the short Apache License 2.0 notice (copyright line and boilerplate), not the license text. The full text is in the LICENSE of the project.
  • github.com/cyphar/filepath-securejoin (linked through go-git into myriarchd and myrctl): the module is BSD-3-Clause AND MPL-2.0 (COPYING.md), so go-licenses reports both. The only linked packages are the root package and internal/consts, which are BSD-3-Clause (LICENSE.BSD). The entry lists exactly these two packages as its scope and fails closed: go-licenses names the library after the module root for every package, so when a dependency update starts to link another package of the module (for example pathrs-lite, which is MPL-2.0), the notices generator and ci/check-release-licenses.sh fail until the package is reviewed.

go-licenses is built without -trimpath: with it the tool finds no packages and exits with status 0. The script fails on an empty report for that reason.

myriarchd dev without the devpg tag

A myriarchd built without devpg cannot start an embedded database. myriarchd dev then fails with an error that points to --dsn:

start embedded postgres: embedded PostgreSQL is not built in: this myriarchd was built without embedded PostgreSQL
(build tag devpg): start `myriarchd dev --dsn postgres://...` against an external PostgreSQL, or rebuild with -tags devpg
(make build / make dev do this)

Give it an external PostgreSQL and it works as before:

myriarchd dev --dsn 'postgres://user:password@127.0.0.1:5432/myriarch?sslmode=disable'

This is how the container setups run myriarchd dev (Docker Compose, Kubernetes deployments with a separate PostgreSQL): they use their own PostgreSQL and need no build tag.

Runtime download of the PostgreSQL binaries

The PostgreSQL binaries are not part of the myriarchd binary. With the devpg tag, the first myriarchd dev start (and the first go test -tags devpg ./internal/devpg/...) downloads them through embedded-postgres from Maven Central and extracts them into the user cache directory:

<user cache dir>/myriarch/embedded-postgres/pg-<version>-<os>-<arch>

PGTEST_CACHE_DIR overrides the cache directory (shared with the test helper internal/testutil/pgtest). The first start therefore needs network access; later starts work offline. The data directory is <data-dir>/postgres/data (default .dev/postgres/data) and survives restarts.

Local tools

make tools (ci/install-tools.sh) puts every tool the gates need into .cache/bin. TOOLS="helm shellcheck" limits the set. The Go tools are built from the pinned modules in ci/tools/. A stamp file next to each binary names the version and a digest of the go.mod and go.sum of its module, so a new pin or a raised requirement rebuilds the tool, also from a tool cache that CI restored from an older run; TOOLS_NO_CACHE=1 ignores the stamps and installs everything again. shellcheck and PowerShell 7 (pwsh) are release downloads, verified against the SHA-256 values in ci/versions.env; pwsh is unpacked into .cache/pwsh/<version> and linked as .cache/bin/pwsh. Put .cache/bin on your PATH to use the pinned versions:

make tools
export PATH="$PWD/.cache/bin:$PATH"

kind (the Kubernetes-in-Docker tool of the probe in hack/kind) is built like the other Go tools. kubectl is a release download from dl.k8s.io, verified against the KUBECTL_SHA256_* values in ci/versions.env: its minor version has to be within the supported skew of the kind node image, which is why the kubectl of a container desktop is not used.

A short outage of a download host or of the Go module proxy does not fail the installation: ci/install-tools.sh retries a release download (kubectl, shellcheck, pwsh, Hugo, Dart Sass, lychee, syft, zizmor, the source of osslsigncode) after a timeout, a failed connection or HTTP 408, 429, 500, 502, 503 or 504, with pauses of 1, 2, 4, … s, no attempt later than 120 s after the first (the policy of the installers), and a Go tool (kind, helm and the others) up to four times, 5, 10 and 20 s apart, when go build fails with a network error of the module download. A 404, a certificate error, a checksum that differs from the pin, a failed module verification and an error of the compiler end the script at once, and the checks after a download are the same as without retries (ci/tests/install-tools-test.sh).

The website tools are release downloads as well, verified against the SHA-256 values in ci/versions.env: Hugo extended (hugo, unpacked from the .pkg on macOS and linked from .cache/hugo/<version>), Dart Sass (sass, in .cache/dart-sass/<version>; Hugo compiles the theme’s SCSS with it) and lychee (lychee, the link checker). They are part of the default TOOLS and installed on demand by ci/website.sh (see Website).

actionlint (the linter of the GitHub Actions workflows) is built like the other Go tools; its ShellCheck integration uses the pinned shellcheck. It belongs to the workflows gate.

The release tooling and the workflow analyser are pinned the same way, so that a release does not depend on whatever a runner ships:

  • gh (the GitHub CLI) and go-winres (Windows version information and manifest resources, built from the tc-hib/go-winres module) are Go tools built from ci/tools/<tool>/.
  • syft (SBOM generation) and zizmor (static analysis of GitHub Actions workflows) are release downloads like shellcheck, verified against the SYFT_SHA256_* and ZIZMOR_SHA256_* values in ci/versions.env. Both publish binaries for macOS and for Linux, for arm64 and amd64 (zizmor only with glibc, unknown-linux-gnu; there is no musl build). syft is not built from source: only the upstream release binaries carry their version, which syft records in every SBOM it makes (a go build reports “[not provided]”), and a Go module for it would bring its whole dependency graph into the repository. The workflows gate runs zizmor (Security rules of the workflows). The SBOM jobs run syft with SYFT_CHECK_FOR_APP_UPDATE=false, so that it does not look for a newer release.
  • osslsigncode (signs and verifies the Authenticode signatures of the Windows release files) is built from the source archive of the pinned tag, verified against OSSLSIGNCODE_SOURCE_SHA256 in ci/versions.env (ci/install-tools.sh osslsigncode). It is a C program, upstream publishes no Linux binary, and the build needs no cmake: one cc call over the C files and a minimal config.h. It is not part of the default TOOLS, because it needs a C compiler, the OpenSSL 3 headers and zlib (Debian and Ubuntu: libssl-dev and zlib1g-dev, for example ci/install-tools.sh apt:libssl-dev apt:zlib1g-dev; macOS: the Xcode command line tools and Homebrew openssl@3, or OSSLSIGNCODE_OPENSSL_PREFIX). ci/release/rehearsal.sh and the CI jobs build and scripts install it on demand. A distribution package is never used, because Ubuntu 24.04 ships 2.8, which predates the verification fixes of 2.12 and 2.13, and the release build runs it on files that a signing provider returns.

ci/tools/update.sh gh go-winres regenerates the modules after a version change. It also raises dependencies for which govulncheck -mode=binary -show verbose .cache/bin/<tool> lists a fixed version (see raised() in the script: golang.org/x/net in gh, golang.org/x/image in go-winres). A raise never lowers a module, and an entry that the tool itself has overtaken is reported as obsolete. Findings in the standard library are fixed by the Go toolchain, and a finding without a fixed version cannot be raised. make vulncheck itself does not scan ci/tools. ci/check.sh verifies that go.mod and ci/versions.env agree and that the syft and zizmor pins are SHA-256 values. ci/tests/tool-downloads-test.sh covers the refusal of a syft or zizmor archive whose checksum differs from the pin, and of a binary that reports another version.

PowerShell is only needed for the Windows installer tests: the pwsh cases in internal/updater and internal/install (the Windows command), ci/tests/installer-verify.ps1 and ci/tests/download-retry.ps1. Without it these tests are skipped and the report says so. MYRIARCH_PWSH points the Go tests at a different pwsh binary. download-retry.ps1 also runs under Windows PowerShell 5.1 (on 5.1 it leaves out the cases that need PowerShell 7 types and smoke-windows.ps1, which requires PowerShell 7); the Windows row of go-test-native runs it under both. The cases behind a proxy that refuses CONNECT start two more fault-server instances and run each download in its own pwsh process (ci/tests/download-retry-proxy-case.ps1), because PowerShell 7 reads HTTPS_PROXY once per process; they run under PowerShell 7.5 or later only, because the runtime reports the status of the proxy from .NET 9 on.

The real-cosign cases of ci/tests/installer-verify.sh and ci/tests/installer-verify.ps1 need cosign 3.0.4 or newer in PATH (make tools TOOLS=cosign puts the pinned one into .cache/bin); they are skipped locally without it and required when CI=true. They run offline against the committed trusted root and fixtures of internal/updater/testdata/sigstore: the upstream collector bundles, and the keyless bundle of checksums.txt of the release v0.1.0-rc.3 (myriarch_0.1.0-rc.3_checksums.txt and its .sigstore.json, byte-identical to the release assets), which the installers must accept for 0.1.0-rc.3 only.

The installer bundles of the server (internal/install) are tested the same way. internal/install/testdata/sigstore holds the same two assets of the release v0.1.0 (myriarch_0.1.0_checksums.txt and its .sigstore.json; the tests check the digests that GitHub reports for the assets), and the tests verify them offline against the trusted root of internal/artifact/testdata/sigstore: the signature is accepted for v0.1.0 and refused for every other version, for a changed file, for another repository, workflow or issuer and for the bundle of an upstream workflow. The success path with archives uses small archives and a verifier that accepts, because the real archives are too large for the repository.

The tests of the install commands run the commands. internal/testutil/linuxhost runs the Linux command in a real shell (the system sh, and dash when it is installed) with stand-ins for sudo, id and mktemp, and with the real curl against a TLS server in TestLinuxCommandWithTheRealCurl and against a real server process in TestInstallCommandThroughTheRealServer (cmd/myriarchd). The Windows command runs in PowerShell 7 with stand-ins for the natives and for the cmdlets that need Windows, also in Constrained Language Mode; these tests need pwsh (MYRIARCH_PWSH, TOOLS_BIN, .cache/bin or the PATH) and are skipped without it. The golden files of the commands are in internal/install/testdata/commands (INSTALL_GOLDEN_UPDATE=1 go test ./internal/install -run TestCommandsGolden writes them). make tools installs shellcheck, which also checks the script of the Linux command.

Gates

Before you open a pull request, run the checks that CI runs (see Continuous integration). The quick version for a code change:

go build ./... && go vet ./... && test -z "$(gofmt -l cmd internal)"
(ulimit -n 10240; go test -race -count=1 ./...)   # database tests use an embedded PostgreSQL, no Docker
cd web && pnpm install --frozen-lockfile && pnpm gen:api && pnpm typecheck && pnpm lint && pnpm test && pnpm build
cd .. && make build && make validate-examples
make docs-check                                   # when you touched documentation or flags

make check-local (ci/check-local.sh) runs every gate in one go, each in its own subshell with one log per gate:

GateWhat it runs
gogo build ./... with and without the devpg tag, vet for Windows and macOS, ci/lint-go.sh (go mod verify, vet, gofmt), the version check (ci/check-versions.sh: VERSION and its copies agree, and no text promises a version that VERSION has reached, see Releasing), the release dependency check and the license check
go-testgo test -race -count=1 ./..., the tagged tests of internal/devpg and the render budget test of internal/blueprint in a run of its own without -race (GO_TEST_RACE=0 drops -race, GO_TEST_DEVPG=0 leaves out the devpg tests, GO_TEST_PERF=0 leaves out the budget test)
webpnpm install --frozen-lockfile, a check that web/src/api/schema.d.ts matches pnpm gen:api, typecheck, lint, the formatting check (pnpm format:check; pnpm format fixes it), tests and the production build
buildmake build
examplesmake validate-examples (myrctl validate --strict on examples/quickstart, gitops-template/config and hack/kind/gitops)
shellcheckci/lint-shell.sh over all shell scripts we ship or run
workflowsci/lint-workflows.sh: actionlint over .github/workflows, zizmor over the workflows, the local action and .github/dependabot.yml (offline; ZIZMOR_ONLINE=1 with GH_TOKEN adds the audits that ask the GitHub API, which the CI job does), and the policy check ci/check-workflows.py (pinned actions, read-only token, forbidden triggers, secrets only for listed deployment jobs, a timeout per job, parity with the gates, the shape of the Scorecard and release workflows; see Continuous integration)
helmci/lint-helm.sh and ci/helm-conform.sh (renders the chart with the example values, which Helm checks against values.schema.json, and validates it against the schemas of Kubernetes 1.30.0, the chart floor, and 1.34.12)
languagethe language guard for English (see below)
docsmake docs-check: generated CLI reference up to date, links, front matter
websiteci/website.sh check: the production build of the website with --panicOnWarning, hugo mod verify, lychee over the built pages (offline, with fragments), markdownlint, the site front matter, the checks of website/scripts and their unit tests (see Website). The first run needs network access for the Go proxy, the npm registry and the tool downloads
licensesthe SPDX header check (scripts/license-headers.py --check), ci/reuse-lint.sh (reuse lint) and the drift check of THIRD_PARTY_NOTICES (scripts/gen-third-party-notices.sh --check)
vulncheckci/vulncheck.sh: govulncheck over the release and the devpg build; a vulnerability that the code calls fails the gate unless ci/vulncheck-allowlist.txt accepts it (ID, review date, reason). Module-level findings are listed. It reads the vulnerability database over the network: offline use SKIP=vulncheck. ci/vulncheck.sh --sarif DIR writes the reports for code scanning instead and never fails on a finding (Vulnerability reports in code scanning)
secretsci/secrets-scan.sh: gitleaks (configuration .gitleaks.toml, default rules plus allowlists limited to one file and one rule) over an export of HEAD, findings redacted. SECRETS_SCAN_WORKTREE=1 ci/secrets-scan.sh scans the work tree instead. Before publication scan the history once: gitleaks git --redact -c .gitleaks.toml
e2escripts/e2e.sh

The go gate also vets the code for Windows and macOS (GOOS=windows go vet ./..., GOOS=darwin GOARCH=arm64 go vet ./...) and compiles the tests of internal/updater for Windows, because the updater runs as SYSTEM there.

The gates run in the order go go-test web build examples shellcheck workflows helm language docs website licenses vulncheck secrets e2e. ci/check-local.sh --list prints them.

VariableEffect
SKIP=e2e,go-testskip these gates
ONLY=web,helmrun only these gates (applied first, then SKIP removes)
LANGUAGE_GUARD=strict|warn|offstrict (default): a finding fails the gate; warn: the gate only warns; off: the gate is skipped
LANGUAGE_GUARD_PATHS="..."space-separated pathspecs of the strict scope
GO_TEST_RACE=0|1go-test: -race on (default) or off
GO_TEST_DEVPG=0|1go-test: run the tagged devpg tests (default) or leave them out
GO_TEST_PERF=0|1go-test: run the render budget test alone and without -race (default) or leave it out; see Render budget
HELM_KUBERNETES_VERSIONS="1.30.0 1.34.12"helm: Kubernetes versions of the schemas that the rendered manifests are validated against
CHECK_LOCAL_STREAM=0|1print every gate to the console in a ::group:: block as well as to its log (default 1 in GitHub Actions, 0 elsewhere)
E2E_*passed through to scripts/e2e.sh
make check-local                          # everything, including the end-to-end test (about 10 minutes or more)
make check-local SKIP=e2e                 # without the end-to-end test
make check-local ONLY=language,docs       # only the documentation checks
make check-local ONLY=website             # only the website build and its checks

Logs are written to .cache/check-local/<gate>.log and the summary to .cache/check-local/summary.txt. A missing tool is reported with the hint to run make tools. The runner always continues after a failed gate and prints a table at the end; the exit code is 1 if any gate failed.

Render budget

The server renders a fleet of 5,000 agents in under 5 seconds (acceptance criterion). TestRenderFleet5000UnderFiveSeconds in internal/blueprint always renders the 5,000 agents and checks the results (no errors, different configurations for different agents), but it holds the duration against the budget only with PERFTEST=1. Inside go test ./... other packages run in parallel, so the wall clock measures the machine as much as the renderer, and the race detector slows the renderer down by 5 to 10 times. The go-test gate therefore runs the test a second time, alone and without -race:

PERFTEST=1 go test -count=1 -v -run '^TestRenderFleet5000UnderFiveSeconds$' ./internal/blueprint

With PERFTEST=1 the test renders the fleet three times, logs every duration and holds the median against the budget. A shared CI runner stalls now and then; one stall does not move the median, while a slower renderer is slower in every render. The -v log of a green run shows how much margin the budget has.

GO_TEST_PERF=0 leaves that run out; the Windows job does, because the budget belongs to the server, which does not ship for Windows. In a -race build the test skips the budget check even with PERFTEST=1 and says so.

Language guard

English is the language of the project (ADR-0020). scripts/check-language.sh reports lines with German umlauts or with several German stop words in tracked text files. It prints only the location and the category, never the line. A line that contains check-language: ignore is skipped. Default excludes (translation catalogs, test fixtures, mock data of the web UI) are listed in scripts/check-language.exclude.

The strict scope of make check-local is: cmd internal scripts ci contrib deploy examples hack web/src docs website .github and the root files README.md CHANGELOG.md CONTRIBUTING.md CODE_OF_CONDUCT.md SECURITY.md MAINTAINERS.md GOVERNANCE.md CONTRIBUTOR_LADDER.md ADOPTERS.md ROADMAP.md AGENTS.md CLAUDE.md. To check a path yourself:

scripts/check-language.sh --untracked -- docs README.md

CLI output

The four binaries follow one contract for their output streams. Scripts, the installers and the smoke tests of a release (ci/release/smoke.sh and ci/release/smoke-windows.ps1) depend on it, and so do operators who write $(myriarch-updater status).

  • stdout carries what the command produces: the version (<binary> version prints one line whose first word is the version), the help text, the result of status, validate, render, config print, verify-bundle and the other commands. The findings and the summary of myrctl validate, myrctl render and myriarchd config validate are results, so they go to stdout as well, with one exception: the ERROR: lines of myriarchd config validate and config print go to stderr.
  • stderr carries diagnostics: log lines and the error that ends a command, printed as <binary>: <message> (myriarch-sim: ...). A command can print results on stdout before it fails (myriarch-updater status prints the service status first and then the error of a missing configuration).
  • Exit codes: myrctl and the auth and config commands of myriarchd use 2 for a usage error and 1 for invalid input or findings. Every other command uses 0 for success and 1 for an error. There are two exceptions. myriarchd migrate also returns 4 for a schema that is incompatible with the binary, and with --check 3 for pending migrations (the code 2 stays unused). myriarchd healthcheck returns 1 for a server that is not healthy, 2 for a usage error and 3 when the server gives no answer (Server behaviour).

Write results with fmt.Fprint*(cmd.OutOrStdout(), ...), never with cobra’s cmd.Print, cmd.Println or cmd.Printf: those write to stderr unless the program called SetOut, which is how myriarch-sim version printed nothing on stdout in the image check of v0.1.0-rc.2. cmd/myriarchd/outputcontract_test.go refuses cmd.Print* and fmt.Print* in the commands. A binary gets a Run entry that takes the argument list and the two writers (Run(args, stdout, stderr) in internal/sim and internal/updater, Run(ctx, args, stdout, stderr) in internal/myrctl) so that a test can use separate writers. internal/testutil/clitest runs the real main of a binary in a child process, and every cmd/<binary> package checks the contract with it (version, help and an unknown command, each with the real os.Stdout and os.Stderr). The smoke test of the release images reads the version of every program from stdout; ci/tests/release-smoke-test.sh runs it against the programs of this tree through a docker double.

Continuous integration

GitHub Actions runs the gates of ci/check-local.sh on every pull request, on every push to main, in a merge queue and nightly (ADR-0032). There are two workflows for the gates, and two more that run the tests which need a machine of their own:

  • .github/workflows/ci.yml has one job per gate group, the platform matrix of the Go tests, the script tests, the end-to-end smoke test (by calling e2e.yml) and the aggregate job ci-ok.
  • .github/workflows/e2e.yml runs scripts/e2e.sh (the smoke test, called from ci.yml), and nightly the load run with 1,000 simulated collectors and make e2e-ui-updates. A manual run (workflow_dispatch) picks the mode.
  • .github/workflows/host-e2e.yml runs the installer and the myriarch-updater on a fresh runner VM, Linux and Windows (Host end-to-end test), and .github/workflows/e2e-kind.yml runs the Kubernetes probe of hack/kind on a kind cluster on a runner (Kubernetes probe on kind). Both run nightly, for pull requests that touch what they test (a path filter) and on a manual run; neither is part of ci-ok, because they need a machine that the other jobs do not (a systemd host with sudo, a container runtime) and take half an hour or more.

The local composite action .github/actions/setup installs Go (from go.mod), optionally Node.js (from .node-version) and pnpm (through corepack), restores the caches and installs the pinned tools of ci/install-tools.sh.

Jobs and gates

Every job runs ci/check-local.sh with ONLY=<gates>, so CI and your machine run the same commands. In GitHub Actions each gate is a collapsible log group; a failed job uploads .cache/check-local/ (the gate logs) for seven days.

JobGates and commandsRunner
gogoubuntu-24.04
go-test-linuxgo-test with -race against a PostgreSQL 17 service (PGTEST_DSN, max_connections 300), then the render budget testubuntu-24.04 and ubuntu-24.04-arm
go-test-nativego-test: macOS with -race, the embedded PostgreSQL and the render budget test; Windows without -race, with PGTEST_SKIP=1 and without the budget test (GO_TEST_PERF=0); on Windows a step runs ci/tests/download-retry.ps1 under Windows PowerShell 5.1 and PowerShell 7macos-latest, windows-latest
db-matrixgo test -race of the packages internal/store, internal/leader, internal/eventbus, internal/pgsession and internal/testutil on an embedded PostgreSQL, once with PGTEST_TLS=1 and once with PGTEST_COLLATION=en_US.UTF-8ubuntu-24.04
webwebubuntu-24.04
buildbuild, examples, then make release-rehearsal (the full release matrix in stage collect, a signing pass with a throwaway test CA, stage apply, dist and reproducibility checks; nothing is published)ubuntu-24.04
shell-helmshellcheck, helmubuntu-24.04
workflowsworkflowsubuntu-24.04
docslanguage, docsubuntu-24.04
websitewebsite (build and checks, never a deployment)ubuntu-24.04
compliancelicenses, vulncheck, secrets; on pull requests also the commits of the pull request (SECRETS_SCAN_RANGE=<base>..<head> ci/secrets-scan.sh), because a secret that one commit adds and a later one removes stays in the historyubuntu-24.04
dependency-reviewon pull requests and merge groups, actions/dependency-review-action: the licenses and known vulnerabilities of the dependencies that the change adds or updates (Dependency review); on every other event a step that says there is nothing to compare, and the job succeedsubuntu-24.04
scriptsmake ci-test (the script tests under ci/ and contrib/; not a check-local gate)ubuntu-24.04
e2ee2e gate: scripts/e2e.sh with the real supervisor and the UI smoke test (e2e.yml, mode smoke)ubuntu-24.04
ci-oksucceeds only when every job above succeededubuntu-24.04

ci-ok is the only required check of the branch ruleset (next to the DCO check). Job names can change without touching the ruleset, and a skipped or cancelled job fails ci-ok. The workflows have no path filters, so ci-ok always reports. The policy check fails when a gate of ci/check-local.sh has no job in ci.yml.

Nightly ci.yml (04:41 UTC) runs the full pipeline, including the smoke test, against the current vulnerability database and upstream downloads. e2e.yml has its own schedule (03:17 UTC) for the heavy variants, the load run and the UI version update test; they do not run inside ci.yml, whose call uses the mode smoke. GitHub disables scheduled workflows of a public repository after 60 days without repository activity.

Reproduce a job locally

make tools                                  # once: the pinned tools in .cache/bin
make check-local ONLY=web                   # the job "web"
make check-local ONLY=licenses,vulncheck,secrets      # the job "compliance"
GO_TEST_RACE=0 GO_TEST_DEVPG=0 GO_TEST_PERF=0 PGTEST_SKIP=1 make check-local ONLY=go-test    # the Windows variant of "go-test"
make ci-test                                # the job "scripts"
CHECK_LOCAL_STREAM=1 make check-local ONLY=docs       # with the log groups of GitHub Actions

For the Linux database tests against an external PostgreSQL see Database tests against an existing PostgreSQL. The Windows and macOS runners are the only place where go test runs on those systems; GOOS=windows go vet ./... and GOOS=windows go test -c -o /dev/null ./internal/updater show compile errors earlier.

Security rules of the workflows

The rules are enforced by ci/check-workflows.py (part of the workflows gate) and recorded in ADR-0032. The check reads the workflow files in .github/workflows and the local actions below .github/actions (action.yml), each with the extension .yml or .yaml because GitHub reads both, so a rule does not depend on how a file is named:

  • Only GitHub-owned actions (actions/*, github/*), each pinned to a full commit SHA with the release in a comment: uses: actions/checkout@<40 hex> # v7.0.1. The same action has the same pin everywhere. Everything else comes from the repository’s own installer (ci/install-tools.sh) or corepack. The one exception is ossf/scorecard-action (ADR-0033): pinned the same way, and only in .github/workflows/scorecard.yml, job analysis (rule R1).
  • The Scorecard workflow has a fixed shape (rule R12). .github/workflows/scorecard.yml runs on a push to main and on a schedule, has one job analysis on a GitHub-hosted ubuntu-NN.NN runner, no env, defaults, container, services or environment, only uses: steps (actions/checkout, ossf/scorecard-action with publish_results: true, github/codeql-action/upload-sarif, optionally actions/upload-artifact) and exactly the job permissions contents: read, actions: read, security-events: write and id-token: write. These are the restrictions of the Scorecard API for published results, applied more strictly where the API is looser (for example R12 allows only the keys name, on, permissions and jobs at the top of the file); the two write scopes need lines in ci/workflow-permissions.txt. A change to the shape starts with the README of ossf/scorecard-action (“Workflow Restrictions”) and a change of rule R12.
  • The release workflow keeps build code away from privileges (rule R13). .github/workflows/release.yml runs only for a pushed tag v* (no workflow_dispatch); uses no cache (no actions/cache, no cache: input, cache: false for actions/setup-go, package-manager-cache: false for actions/setup-node, no local action or called workflow); names only the literal environment release; has a job guard that every other job needs; and runs setup-node, setup-go and the build commands listed in the docstring of ci/check-workflows.py (pnpm, npm, node, go, goreleaser, make and similar) only in jobs without a write scope, id-token, attestations or an environment. Every such privileged job has the environment release itself or needs a job that has it. No job has an if: with always(), failure(), cancelled() or success() and none uses continue-on-error, because either makes needs: ineffective. A privileged job uses no shell that reads its script from standard input (bash <<EOF, | sh) and no shell: other than bash or sh. The check matches a command at the start of a command, so comments, quoted text, here-documents, case patterns and arrays that mention go or make are fine; a program started through a variable, a script or an interpreter that is not a shell is out of reach, so the scripts that privileged jobs call are tested with a PATH without go, node and pnpm.
  • zizmor audits the workflows. ci/lint-workflows.sh runs the pinned zizmor (ci/install-tools.sh zizmor) between actionlint and the policy check: template injection, dangerous triggers, excessive permissions, undocumented permissions, persisted credentials, cache poisoning, unpinned and impostor actions, secrets outside environments, unsafe writes to GITHUB_ENV and GITHUB_PATH, missing concurrency limits and the cooldown of Dependabot updates. It uses the pedantic persona. A permission needs a trailing comment on its own line (security-events: write # upload the results), and a workflow needs a concurrency group; scorecard.yml has none, because rule R12 allows only the keys name, on, permissions and jobs at its top (stricter than the Scorecard API, which does not check concurrency), and is ignored for that audit in zizmor.yml. The gate runs offline, so it needs no network and no token. The workflows job of ci.yml sets ZIZMOR_ONLINE=1 and passes the read-only token of the run as GH_TOKEN, which switches on the audits that ask the GitHub API about the pinned actions. To run them locally, export a GH_TOKEN and ZIZMOR_ONLINE=1. A finding fails the gate, and so does a file that zizmor cannot parse (--strict-collection; by default zizmor skips such a file with a warning). Fix the workflow; if the finding is a false positive, ignore it as narrowly as possible, with the reason next to the ignore: a trailing comment # zizmor: ignore[<audit>] on the line the finding names (it must be the line of the run: key for a script), with a comment line above it or text behind the bracket that gives the reason, or an entry under rules.<audit>.ignore in zizmor.yml (audit and workflow file, a line number where the finding is about one place) under a comment that gives the reason. An entry is the base name of the file (ci.yml), because zizmor compares nothing else: a directory never matches, and action.yml would match every local action, so a finding in a local action is ignored with a comment in the file. Rule R14 of the policy check enforces this: zizmor.yml has nothing but ignore entries (no disable, no config, no other key, no wildcard, no directory), written as a block with one audit per line, each under a comment line whose reason has at least one word; an inline ignore needs a reason with a word as well, and a bare # zizmor: ignore fails. R14 also makes sure that the policy check and zizmor read the same lines: a file that zizmor reads (zizmor.yml, the workflows, the local action, .github/dependabot.yml) fails when it is not UTF-8 or holds U+0085, U+2028 or U+2029 (a YAML parser takes them for line breaks, the check splits at LF and CR), a control character other than TAB, or a byte order mark. The environment variables ZIZMOR_CONFIG, ZIZMOR_OFFLINE, ZIZMOR_NO_ONLINE_AUDITS and GH_HOST are ignored by the script, which passes --gh-hostname github.com (the online audits and the token go to github.com and nowhere else). ci/tests/lint-workflows-test.sh checks with the real zizmor that a finding fails the gate, also one that only the pedantic persona reports, and that an ignore covers only its audit and file; with a stub it checks which options and environment variables reach zizmor.
  • CodeQL scans the code (.github/workflows/codeql.yml, results in the code scanning alerts of the repository). One matrix job analyze per language, with the security-extended queries: go with a manual build that CodeQL traces (go build ./..., then with the tags embedui,devpg and a placeholder page for the git-ignored internal/webui/dist, then for GOOS=windows (also with the tags, because internal/devpg/lock_other.go is devpg && !unix and no other build compiles it) and GOOS=darwin, so that the code behind the build tags and the Windows-only code of the updater are analysed), javascript-typescript (the web UI and the JavaScript scripts of the website), python (ci/, scripts/, website/scripts/, assets/brand/source, contrib/enterprise-mirror) and actions (the workflows and the local action). It runs on pushes to main, on every pull request to main and weekly. Pull requests from forks and from Dependabot are analysed too, because GitHub always lets code scanning upload the results of a pull_request run (default setup would skip forks; advanced setup does not). The job’s one write scope, security-events, is listed in ci/workflow-permissions.txt with its review: the token of a fork run is read-only, a Dependabot run receives the scope (the permissions key raises the read-only default of Dependabot), and the scope also allows dismissing alerts. That stays harmless because the steps of the job compile the code and run none of it, and the job has no secret. Default setup of code scanning must stay off in the repository settings, because this workflow replaces it. init, analyze and the upload-sarif step of scorecard.yml use the same pin of github/codeql-action (rule R2). A new language, build tag or operating system goes into the matrix and into the build step, and the first run has to show that the extractor saw the new files; a new build tag in ci/check-local.sh goes into the build step as well.
  • govulncheck reports to code scanning from a job that runs no code (.github/workflows/vulncheck.yml). The jobs source and binaries hold contents: read, run govulncheck and keep the SARIF reports as artifacts of the run; only the job upload holds security-events: write (the one line of the workflow in ci/workflow-permissions.txt), and its steps are a checkout without credentials, the download of those artifacts and github/codeql-action/upload-sarif at the pin of rule R2. The workflow does not run for pull requests and has no secret. ci/tests/vulncheck-test.sh pins this shape on the real file (no script and no other action in upload, no other write scope, no pull request trigger, no expression in a script). The scan of the released binaries starts only after the keyless verification of the release files (Vulnerability reports in code scanning).
  • A read-only token: permissions: contents: read at the top of every workflow. A job-level write scope needs a line in ci/workflow-permissions.txt (<workflow file> <job> <scope>) and a review of the job.
  • The dependency review has no privilege of its own. The job dependency-review of ci.yml uses actions/dependency-review-action, a GitHub-owned action pinned like every other, with the read-only token of the workflow: no permissions key, no secret, no comment in the pull request (comment-summary-in-pr: never), and no request to a third party (show-openssf-scorecard: false; the action would send the repositories and names of the added packages to api.securityscorecards.dev and api.deps.dev). It succeeds on every event other than pull_request and merge_group through a step that says so, because a skipped job fails ci-ok. ci/tests/license-allowlist-test.sh pins these inputs and checks that ci-ok waits for every job of ci.yml.
  • No pull_request_target and no workflow_run, persist-credentials: false on every checkout, a timeout on every job, and no github.event.*, github.head_ref or inputs.* expression inside a run: block (pass it through env:).
  • Secrets only for listed deployment jobs. A ${{ secrets.NAME }} expression passes the check only when all three hold: ci/workflow-secrets.txt lists <workflow file> <job> <NAME>, the job declares environment: (so the protection rules of the environment apply before the job starts), and the expression is the whole value of a key in the env: of the job or of one of its steps (KEY: ${{ secrets.NAME }}). Everything else fails, also inside a listed job: a secret in run: text, with:, if:, the workflow-level env:, a flow mapping or a block scalar, in index syntax (secrets['NAME']) or as the argument of a function (toJSON(secrets)), and every secrets: key (secrets: inherit, also in a flow mapping that spans lines). The same words inside a run: script, another block scalar or a quoted string are text and no key, so a step may write a manifest that contains secrets:. A line of the list whose workflow file, job or secret is gone, a malformed line and a duplicate line fail too. github.token is not a secret in this sense: use it (not secrets.GITHUB_TOKEN) when a step needs the token of the job.
  • Toolchains from the repository: go-version-file: go.mod and node-version-file: .node-version, never a literal version.
  • Caches come from actions/cache and the setup-* actions only. A cache written by a pull request is not restored on main. A release workflow must not use caches (TOOLS_NO_CACHE=1; rule R13).
  • Artifacts of a failed run are an allow-list of log files. Artifacts of a public repository can be downloaded by any signed-in user, so they never contain tokens, cookies or database files.
  • First-time contributors’ pull requests wait for a maintainer to approve the workflow run.

Dependency review

The job dependency-review checks, in every pull request and merge group, the dependencies that the change adds or updates (ADR-0038). The dependency graph of GitHub compares the manifests and lockfiles of the pull request with those of its base, and actions/dependency-review-action fails the job when a dependency in the runtime scope

  • carries a license that is not in the allow-licenses of .github/dependency-review-config.yml, or
  • has a known advisory of high or critical severity in the GitHub Advisory Database.

The review checks what the dependency graph of GitHub reports for the change with the scope runtime; which ecosystems (Go, npm, Python, GitHub Actions) that covers follows the dependency graph and is to be confirmed by the first runs. The repository has the Go modules of the product and of ci/tools, the lockfiles of web/, website/ and the brand generator, the Python requirements of the tools and the workflows. The thresholds (fail-on-severity: high, fail-on-scopes: runtime) are inputs of the job in ci.yml; the configuration holds what is allowed. A dependency without license information is only listed in the job summary, and a license expression that is not valid SPDX fails the job. The log and the job summary name the manifest, the package, the version and the license or the advisory. The check needs the dependency graph of the repository and runs only in CI; ci/tests/license-allowlist-test.sh runs everywhere.

The lists. ci/license-allowlist.txt is the source of both: the entries of the list release are the allowlist of the release builds (ci/check-release-licenses.sh, License check of the release builds), and all entries together, the CNCF allowlist and Apache-2.0, are the allow-licenses of the review. The review is a tripwire, and the release check stays binding for the Go packages of the release builds: a Go package whose license only the list review allows passes the pull request and fails the release check as soon as a release build links it. The release check does not see the npm packages that the web UI bundle carries into myriarchd: the review is the only check of their licenses against a list, and tslib (0BSD, a license of the list review) ships in the bundle today (ADR-0038, decision 6). The test ci/tests/license-allowlist-test.sh fails when the configuration and the file differ, so change both. A change of the list release is a decision record.

When the review fails. Do not add the dependency, or take the license to the maintainer, when the package really carries a license outside the list. When the dependency graph reports a license wrongly or as a combination (a documentation license next to the code license, test data, a font), add the package URL without a version to allow-dependencies-licenses, in a group under a comment that gives the reason: the test refuses an entry with a version, a repeated entry and a group without a reason. The reason names the expression that the dependency graph reports for the package (licenseConcluded in the SBOM that gh api repos/<repository>/dependency-graph/sbom returns) and why the package is acceptable. That expression, not the license that the package declares, decides whether the review needs an exception: A AND B needs both identifiers on the list, A OR B one of them, and a LicenseRef identifier of the graph’s own, such as LicenseRef-bad-bsd-2-clause-isc, is on no list. Remove an exception when the graph reports an expression that the list covers for every version of the package that a lockfile resolves. The action ignores the version of a package URL, so an exception also covers the later versions of its package; look at the license of such a package when you update it. An advisory that does not apply goes to allow-ghsas with its GHSA ID and a reason; govulncheck (gate vulncheck) is the check that knows which vulnerable symbols the code calls. The configuration comes from the checkout of the pull request, so a change of the lists or of an exception is a change of policy and is reviewed as one. pkg:npm/elkjs (EPL-2.0, a dependency of Mermaid) has no exception on purpose: whether the website owes a notice for it is still to be decided (ADR-0038, decision 5). A change fails the review when it adds elkjs or changes its resolved version (Mermaid 12.1.0 resolves 0.9.3 today); a Mermaid update that keeps the version passes. When the job fails because GitHub cannot return the comparison (a merge group that is not processed yet, for example), nothing runs it again: re-run the job, and queue the pull request again when its merge group failed.

Vulnerability reports in code scanning

.github/workflows/vulncheck.yml reports the results of govulncheck to the code scanning alerts of the repository (Security tab, tool govulncheck) as SARIF, on every push to main, every day at 05:23 UTC (an advisory can appear without a change of the code) and by hand. It does not run for pull requests and it blocks nothing: the check that blocks is the gate vulncheck in the job compliance of ci.yml. Four categories hold the reports:

CategoryJobWhat it looks at
govulncheck/source-releasesourcethe code of main, built like a release (ci/vulncheck.sh --sarif DIR)
govulncheck/source-devpgsourcethe code of main with the build tag devpg (ci/vulncheck.sh --sarif DIR)
govulncheck/binariesbinariesthe programs of the latest stable release, at module level (ci/vulncheck-binaries.sh); every run that names no release
govulncheck/binaries-manualbinariesthe programs of the release that a manual run names (release_tag), at module level

The job upload is the only one with a write scope. It uploads each report with its category, so a vulnerability that a later run no longer finds is closed by that run. A finding that ci/vulncheck-allowlist.txt accepts (the file of the gate, with ID, review date and reason) is left out of every report while its entry is in force, so code scanning shows what the project has not decided about. An entry accepts until its review date has passed: from the day after, the finding is reported again and becomes an alert, while the gate only prints a warning and passes. Review the entry and move its date, or remove it. A manual run can name another release (release_tag, for example v0.2.0-rc.1); an empty value means the latest stable release, which https://github.com/<repository>/releases/latest leads to. A run that names a release uploads its binary report to govulncheck/binaries-manual: a category holds the analysis of the last upload to it, so the same report in govulncheck/binaries would replace the analysis of the latest stable release until the next push or daily run, and the alerts would close and reopen between the two releases. The alerts of govulncheck/binaries-manual stay until the next manual run that names a release replaces them (dismiss them in the Security tab when the release is of no interest any more). The first run on main has to show that GitHub accepted the three reports: the step upload-sarif waits for the processing and fails when code scanning refuses a file.

The scan of the released binaries checks before it scans, and every step fails closed (ci/vulncheck-binaries.sh, codes in brackets): it downloads checksums.txt and its keyless bundle without credentials; ci/release/verify-blob.sh checks the bundle against the frozen identity of release.yml at the tag of the version, issued by GitHub Actions (signature_invalid); only then it downloads every archive of myriarchd, myriarch-updater, myrctl and myriarch-sim that the signed list names for the version and compares each with its SHA-256 (checksum_mismatch); only then it reads the one member that is named like the program out of each archive, as a regular file of at most 512 MiB, and nothing else (archive_invalid), and scans it with govulncheck -mode binary -scan module. The programs are not run. Every download has a size limit (curl --max-filesize: 1 MiB for checksums.txt and its bundle, 200 MiB for an archive; a file above it is download_failed and not retried), and the signed list may name 64 archives at most (no_archives). Run it yourself with the pinned tools (it needs network access and downloads the 16 archives of the release, about 120 MB):

ci/install-tools.sh cosign govulncheck
ci/vulncheck-binaries.sh --out .cache/vulncheck                   # the latest stable release; --tag v0.1.0 names another one
ci/vulncheck.sh --sarif .cache/vulncheck                          # the source reports

What a result of the binary scan says. The release binaries are built with -s -w, and govulncheck cannot read the symbols of a stripped program. At its default level it then takes every vulnerable symbol of every affected module for present and reports each as called, also in packages that the program does not link. The scan therefore runs at module level (-scan module), ci/vulncheck-sarif.py refuses a report of another level, and every result is a warning. A result means that a release was built with a module version or a Go version that the advisory affects (stdlib is the Go version); it does not say that a binary contains or calls the vulnerable code. That is what the source analysis, the gate and the two source reports, tells. The results describe the release, not a branch: code scanning has no file and line for them, so each result is anchored at go.mod and names the affected binaries in its message.

What to do with a finding of the binary scan. A vulnerability that only the binary report has (the code of main does not report it, for example because main uses the fixed version already) is fixed by a release that is built with the fixed module or Go version; once that release is the latest stable one, the next daily run closes the alert. When the finding does not apply to the release, dismiss the alert in the Security tab with a reason that names the release (GitHub records who dismissed it and when). Do not add its ID to ci/vulncheck-allowlist.txt for that: the file belongs to the gate, and an entry would also let a later call of the same vulnerability pass the gate. A run that cannot reach GitHub or the vulnerability database fails in the Actions list and leaves the earlier alerts as they are.

Add a deployment secret

To let a deployment job read a secret:

  1. Create the GitHub environment (repository settings, Environments) with its protection rules, for example a deployment branch policy or required reviewers, and store the secret in that environment, not in the repository.
  2. Declare environment: <name> in the job and pass the secret as KEY: ${{ secrets.NAME }} in the env: of the job or of the step that needs it. The step reads the environment variable.
  3. Add the line <workflow file> <job> <NAME> to ci/workflow-secrets.txt. The pull request says which secret, why the job needs it and which environment protects it. Fork pull requests receive no secrets from GitHub.

Update an action pin

Dependabot proposes the updates weekly in one pull request and keeps the comment in step with the SHA (Dependency updates). By hand: look up the commit of the release tag and replace the SHA and the comment in every place the action is used (ci/check-workflows.py fails when the pins differ):

git ls-remote --tags https://github.com/actions/checkout.git 'refs/tags/v7.0.1' 'refs/tags/v7.0.1^{}'   # the ^{} line, if present, is the commit
python3 -I ci/check-workflows.py --remote        # compares every pin with its tag on GitHub (needs network)

The pin of ossf/scorecard-action is updated the same way (Dependabot’s github-actions entry, or by hand with the tag of the release); the SHA must be a commit of ossf/scorecard-action itself, which the Scorecard API verifies and --remote checks through the tag.

Moving .node-version to a Node.js release without corepack (Node.js 25 and later) needs a pinned npm install -g corepack@<version> step in .github/actions/setup/action.yml. Move it to the next LTS line only after its first LTS release.

Release workflow

.github/workflows/release.yml is the workflow that builds, signs, verifies and publishes a release when a tag v* is pushed (ADR-0035; the maintainer’s runbook is Releasing). It is not part of ci-ok and cannot be started by hand, so you test it in pieces:

bash ci/tests/run-all.sh static             # includes the three release tests below
bash ci/tests/release-scripts-test.sh       # the scripts of ci/release with stubs for cosign, gh and crane; the real cosign checks a fixture
bash ci/tests/release-smoke-test.sh         # the smoke tests (ci/release/smoke.sh) against test doubles; the image checks also run the real binaries of this tree
bash ci/tests/release-workflow-test.sh      # the shape of release.yml: jobs, needs, permissions, environments, artifacts
bash ci/tests/download-retry-test.sh        # the retry policy of the installer and smoke downloads against a local HTTP fault server (python3), including an HTTPS proxy that refuses CONNECT
make release-rehearsal                      # the path of a release in a snapshot, including the staging of the 40 files

The real-cosign cases of release-scripts-test.sh need the pinned cosign (make tools TOOLS=cosign); they are skipped locally without it and required when CI=true. The workflow itself is checked by the workflows gate (actionlint, ci/check-workflows.py, zizmor), which must be clean from the first commit of any change.

Rules for a change to the release workflow:

  • Never rename release.yml and never move signing into a reusable workflow. The file name is part of the signing identity that every installer, myriarch-updater and the documented commands check (ADR-0035, point 10, and ADR-0029). ci/release/identity.env holds the values, and a test pins them to the installers, internal/brand and the ADR. ci/release/meta.sh refuses a run whose GITHUB_WORKFLOW_REF is not this file at the tag.
  • A new job decides its place under R13 first. If it builds or runs code of the repository (Go, Node.js, pnpm, goreleaser, make), it has contents: read and no environment, and it sits before the privileged jobs. If it needs a write scope, id-token, attestations or the environment, it calls only scripts of ci/release/, cosign, crane, gh or actions/attest, takes artifacts by ID and digest, and needs guard and the approval job transitively. Add the table row of ci/tests/release-workflow-test.sh in the same change, and the line of ci/workflow-permissions.txt with its reason for every write scope.
  • No ${{ }} in run: text. Values reach scripts through env: of the step, from needs.guard.outputs.* or other jobs' outputs. github.token appears only in the step-level env: of the steps that need it.
  • Scripts first. Logic goes into a tested script of ci/release/ with stable error codes ([code] in the message) and tools only from TOOLS_BIN, not into run: text.
  • Pins of actions follow Update an action pin; the release workflow uses the same SHAs as the others.

Dependency updates

Dependabot (.github/dependabot.yml) proposes version updates every Monday, as one grouped pull request per directory, and security updates as soon as an advisory has a fix. Every entry has a cooldown of 7 days (zizmor, audit dependabot-cooldown): a version is proposed no earlier than a week after its release; security updates ignore the cooldown. The pull requests go through the same checks as any other change, and several of them need a follow-up commit on the same branch:

Ecosystem and directoryGroupWhat else the update has to change
GitHub Actions: / and /.github/actions/setupgithub-actionsNothing: Dependabot rewrites the SHA and the version comment (Update an action pin)
Go: /, the productgo-modulesmake notices: THIRD_PARTY_NOTICES lists the version of every linked package, and the licenses gate fails on a drift
pnpm: /webweb-uimake notices for the bundled packages. TypeScript majors are held back (ignore in dependabot.yml) until openapi-typescript and typescript-eslint support TypeScript 7
pnpm: /websitewebsitewebsite/data/third_party.yaml for a shipped package and the Mermaid version in website/config/_default/params.yaml (website/scripts/check-notices.py checks both)
pip: /assets/brand/sourcebrand-pythonCompare the output of the brand generator (below)
npm: /assets/brand/sourcebrand-nodeCompare the output of the brand generator (below)
Go: /ci/tools/*, the CI toolsnoneSecurity updates only (below)

Dependency review. A Dependabot pull request goes through the dependency review like any other change. It fails when it adds or updates a package with a license outside ci/license-allowlist.txt, which happens mostly in the tool trees (ci/tools, web/ and website/ development dependencies, the brand generator). Add an exception with its reason to .github/dependency-review-config.yml as a follow-up commit, or decide against the update.

Sign-off. DCO is a required check next to ci-ok. The DCO app (DCO-2) passes the commits that a bot authored without a sign-off, so a Dependabot pull request is green as Dependabot made it. Leave its commits alone: git rebase --signoff and a force push would replace the commits that GitHub signed for Dependabot with local ones, and Dependabot could no longer rebase or update the pull request. A commit that you add to the branch, such as a follow-up of the table, is yours and carries your sign-off (git commit -s, section “Sign-off” of CONTRIBUTING.md). Dependabot stops rebasing a pull request once someone has pushed to its branch, so push the follow-ups last. Merge by squash or rebase, and read the release notes before you merge a major version.

Security updates need the repository setting “Dependabot security updates”. They do not wait for Monday, and they ignore the groups and the limit of open pull requests. The setting is not limited to the entries of dependabot.yml, so an update can also arrive for a file that “Updated by hand” below lists. A pull request for ci/tools/reuse needs a look at the Python 3.9 floor: the requirements are hash-pinned for Python 3.9 and newer, and CI runs a newer Python, so it cannot see a break of the floor. Compare the pull request with the output of the uv pip compile --python-version 3.9 commands in ci/versions.env, and build the tool once with a Python 3.9 interpreter (REUSE_PYTHON).

Website overrides. The Vercel CLI and markdownlint-cli2 pin some of their own dependencies to vulnerable versions, and website/package.json replaces those through pnpm.overrides (website/README.md, section “Node dependency overrides”, gives the reason for each entry). After an update of one of the two tools, run pnpm install and pnpm audit in website/ and drop an entry whose removal changes nothing in pnpm-lock.yaml but the overrides: block. pnpm audit still lists a few accepted findings, which the README names with their reasons; any other finding needs a decision.

CI tools. The module of a tool (ci/tools/<tool>) names the tool only in a tool directive, so every requirement in it is marked // indirect, and Dependabot proposes version updates for direct requirements only. The tool versions are the pins of ci/versions.env, and ci/check.sh fails when a go.mod names another version. The entry therefore sets open-pull-requests-limit: 0 and exists for security updates only. Dependabot runs go mod tidy on every Go module it updates, so such a pull request carries more than the vulnerable requirement: the tidy run adds go.sum lines for modules that have nothing to do with the advisory (from under ten to a few hundred, depending on the tool), while ci/tools/update.sh does not run it. Read the go.mod diff against the advisory. A change of either file rebuilds the tool (see “Local tools”), so CI builds and uses the raised tool in the pull request. A security update of the tool itself fails ci/check.sh until ci/versions.env names the new version; then run ci/tools/update.sh <tool>. That script builds go.mod and go.sum again from the pin, which drops a raised requirement that the new release of the tool does not require itself, so look at the Dependabot alerts of the module after such an update.

Brand generator. No gate runs assets/brand/source/make_assets.sh, and the committed files depend on the versions of its packages. After an update of one of them, run the script into an empty directory and compare its files with assets/brand/ (assets/brand/README.md says what to expect).

Updated by hand. The digests of the container images (ci/pin-images.sh; ci/check.sh keeps the Dockerfiles, the compose file and ci/versions.env in step), reuse (ci/tools/reuse, hash-pinned for the Python 3.9 floor; see the security updates above), the Docsy theme (website/go.mod; after an update website/scripts/check-overrides.py shows which upstream file changed) and the pins of ci/versions.env: the tool versions and the downloads with a SHA-256, such as Hugo, Dart Sass and lychee.

Licenses, headers and third-party notices

The project follows the REUSE specification 3.3 (ADR-0030). Three things are checked by the licenses gate (make licenses-check runs them without the other gates): the SPDX headers of source files, reuse lint, and the drift of THIRD_PARTY_NOTICES.

Headers

Every source file in scope starts with these two lines in its comment syntax, followed by one empty line (HTML and Helm templates are the exception: their content follows the header directly):

Copyright The Myriarch Authors
SPDX-License-Identifier: Apache-2.0

The line comment is // for Go, TypeScript and JavaScript, # for shell, PowerShell, Python, Dockerfiles, the Makefile, service units and the sed templates under scripts/e2e/ui-updates/, and -- for SQL. CSS uses a block comment, HTML a comment block directly above the original content, and Helm templates a {{- /* ... */ -}} comment, which leaves the rendered output unchanged. A shebang, a shellcheck directive, a Dockerfile parser directive (# syntax=...), a Python coding line and a leading <!doctype> line stay in front of the header; a Go //go:build line and a package comment come after it. A file that has to hold other SPDX tags or copyright lines (test data, generators of notices) wraps them in REUSE-IgnoreStart and REUSE-IgnoreEnd comment lines.

make license-headers                            # python3 -I scripts/license-headers.py --fix
python3 -I scripts/license-headers.py --check   # reports "path: reason" (missing, wrong text, wrong position, refused)
python3 -I scripts/license-headers.py --list    # style and path of every file in scope

--fix only inserts missing headers, is idempotent and refuses (exit 1, “manual fix”) a file that already holds an SPDX-License-Identifier: outside the expected block. web/src/api/schema.d.ts is generated: pnpm gen:api adds the header through web/scripts/add-license-header.mjs.

REUSE.toml and reuse lint

Files without comments (YAML, JSON, data, images, Markdown) and third-party content are covered by annotations in REUSE.toml. Each annotation has precedence = "override", so when several annotations match a file the last one wins. A comment above each annotation names the source of third-party content (URL, tag or commit). The license texts are in LICENSES/ (only Apache-2.0 and CC-BY-4.0); reuse lint fails on an unused license. LICENSE in the repository root is the unchanged text of the Apache License 2.0.

ci/install-tools.sh reuse    # builds the pinned reuse into .cache/reuse/<version>, linked to .cache/bin/reuse
ci/reuse-lint.sh             # LANGUAGE=en reuse --root <repo> lint

The wrapper sets LANGUAGE=en: a reuse that was built where msgfmt exists would otherwise print German.

reuse is pinned to 5.1.1 (REUSE_VERSION in ci/versions.env), the last release that supports Python 3.9; version 6 and later need Python 3.10. The installer needs Python 3.9 or newer (REUSE_PYTHON selects the interpreter; Debian also needs the package python3-venv). It creates a virtual environment and installs only hash-pinned packages (ci/tools/reuse/requirements.txt and ci/tools/reuse/build-requirements.txt, with poetry-core for the build from source). ci/check.sh verifies that the inputs pin the same version. To change the pin, edit ci/versions.env and the .in files, then regenerate the lock files with uv so that they resolve for every Python from 3.9:

uv pip compile --no-config --python-version 3.9 --generate-hashes --no-header --no-annotate \
  ci/tools/reuse/requirements.in -o ci/tools/reuse/requirements.txt
uv pip compile --no-config --python-version 3.9 --generate-hashes --no-header --no-annotate \
  ci/tools/reuse/build-requirements.in -o ci/tools/reuse/build-requirements.txt

Third-party notices

THIRD_PARTY_NOTICES is generated and never edited by hand:

make notices                                   # scripts/gen-third-party-notices.sh, writes THIRD_PARTY_NOTICES
scripts/gen-third-party-notices.sh --check     # regenerates into a temporary file and fails on a difference
scripts/gen-third-party-notices.sh --out FILE  # writes somewhere else

The generator needs go, go-licenses in .cache/bin (ci/install-tools.sh go-licenses), python3 and the installed web dependencies (pnpm install --frozen-lockfile in web/). It combines:

  • go-licenses report for every command (myriarchd, myrctl, myriarch-updater, myriarch-sim) and for linux, windows and darwin, plus one run with the devpg tag, whose packages are marked as used by development builds only. It reproduces every distinct license text verbatim and prints identical texts once (see below), and every NOTICE file of each linked module verbatim with its component.
  • The Vite license report (build.license in web/vite.config.ts) for the packages bundled into the embedded web UI.
  • ci/license-overrides.txt, shared with ci/check-release-licenses.sh. Each line maps a module path prefix to the license that go-licenses reports, the license to declare, the license file (or -), the scope (* for every package below the prefix, or a comma-separated list of exact import paths that fails closed when another package of the prefix is linked) and the reason. The generator fails on an override that matches nothing, and on any linked library without a license text.
  • scripts/notices/third-party-texts.json with texts that cannot be read from the build: the Go LICENSE and PATENTS (the Homebrew toolchain ships neither) and npm packages that publish no license file.

The output has no absolute paths, timestamps or toolchain versions, so the drift check is stable. It also records the origin of the embedded Mozilla root bundle, of time/tzdata, and the run-time download of PostgreSQL by myriarchd dev.

Identical license texts are printed once. Section 5, “License texts”, lists every distinct text under an ID ([T1], [T2], …; numbered in the order of first use, so adding a dependency can renumber the later texts), and each component entry names its license files with a line License text: [Tn]. Two texts share an ID only when they are byte-identical after the normalization of the generator (LF line endings, exactly one trailing newline); a text that differs in any byte, such as a copyright line or an appendix, has its own ID. NOTICE files are never shared: they stay verbatim in the entry of the component that ships them. An entry of section 5 gives the ID, the component and file where the text was first used and its first line; it does not name a license, because the license expression of a component does not say which license a file of it holds. The renderer fails when the file is larger than 1,000,000 bytes (--max-bytes), which is the limit of the publication gate; this applies to make notices and to the drift check of the licenses gate.

If the generator fails because a bundled npm package has no license text, add an entry to the npm list of scripts/notices/third-party-texts.json with name, version (the bundled version; the generator fails when it differs), identifier (the SPDX expression from package.json), source (the URL of the upstream license file at the tag or commit of that version), sha256 of the text and the text itself. Download the file into a new, empty directory, check it against the upstream repository at that tag, compute the SHA-256 yourself, and run make notices. Entries are refreshed when the package version changes.

Binary archives (GoReleaser), container images and the running server ship the files: archives hold LICENSE and THIRD_PARTY_NOTICES at their root, images hold them under /usr/share/licenses/myriarch/, and myriarchd serves them at /licenses/LICENSE and /licenses/THIRD_PARTY_NOTICES without login. Settings > System links both.

Kubernetes probe on kind (hack/kind)

make kind-probe runs the whole system on a local kind cluster from zero: it builds the pinned tools, creates the cluster with a local registry, builds the server and collector images for the node architecture with the crane scripts (IMAGE_PLATFORM), installs PostgreSQL, an SSH Git server, the Helm chart, the simulator and one real collector in container mode, and runs numbered checks (sign-in with the bootstrap admin, registration with declared enrollment tokens, a configuration rollout, a collector version update by the calls of the UI button with an automatic rollback and a retry, a pod restart, the leader takeover, NetworkPolicy enforcement, the Dex quickstart, log and secret hygiene). It needs Docker, no sudo, no browser and no edits of /etc/hosts; it exits 0 only without a failed check. make kind-up creates only the cluster, make kind-down removes it (ALL=1 also the registry and the work directory .cache/kind).

The probe is part of the language guard (hack is in the strict scope) and of the static gates (ci/lint-shell.sh, ci/check.sh verifies the digest pins, ci/helm-conform.sh renders the probe values). Prerequisites, stages, environment variables and troubleshooting: hack/kind/README.md.

In CI. .github/workflows/e2e-kind.yml runs make kind-probe on an ubuntu-24.04 runner, whose Docker engine runs the cluster: every night, for pull requests that touch the chart, the updater, the images of the probe (their build scripts and the pins of ci/versions.env), the tools it installs or the scripts of the probe, and on a manual run, which can skip optional parts (PROBE_SKIP). It is not part of ci-ok and not a gate of the release; before a tag the last green run of the commit replaces a local make kind-probe (Releasing). The matrix has one entry per Kubernetes version: image-var names the pin of hack/kind/versions.env that holds the node image (KIND_NODE_IMAGE today), so the digest stays in one place and ci/check.sh keeps holding it; a second version is a second pin and a second entry. The summary block of the probe goes to the job summary. A failed run uploads an allow-list from .cache/kind (the log of the probe, the result list, the durations, the logs of the pods and the diagnostics of hack/kind/diag.sh) for seven days, and never the kubeconfig, the generated secrets, cookie jars or the scan dump of the hygiene stage (ci/tests/workflow-artifacts-test.sh). The step that runs the probe has a timeout of its own, below that of the job, so that a probe that hangs counts as a failed step and still uploads its logs; a job that runs out of time is cancelled, and the upload is skipped for a cancelled job. The path filter of pull requests names the files that the probe reads or runs besides hack/kind (the image scripts and what they source, the pins, the tool installer, the Makefile, the setup action of the job and the Node.js version it installs), and the same test holds the list. The runner pulls the images anonymously, so Docker Hub can limit the pulls of a busy day; the workflow has no secret to log in with, and a rerun of the job is the answer.

Chart installation test on kind (ci/kind-chart-test.sh)

ci/kind-chart-test.sh --fresh installs the two Helm charts on a fresh kind cluster, as a user does, and prints PASS / FAIL per step like the probe: the server chart in a restricted namespace with helm test (the Pod checks /readyz and an OpAMP WebSocket handshake), the collector chart as a StatefulSet in a restricted namespace and as a DaemonSet in a privileged namespace (the documented exception for the hostPath volume), every agent registered, online and healthy at the server (REST API), a clean uninstall, and a helm upgrade from the chart of the tag v0.1.0 (from Git, with the image of that release pinned by digest as UPGRADE_FROM_SERVER_IMAGE) to the chart and the image of the commit. It reuses hack/kind (cluster, registry, images.sh with the server image and the updater of the commit, diag.sh), so it shares the cluster with make kind-probe: run one of the two at a time. It needs Docker and is not part of make check-local; the gate only holds its shell syntax (ci/lint-shell.sh) and the shape of its workflow job (ci/tests/workflow-artifacts-test.sh). Stages and environment: hack/kind/README.md.

In CI. The job charts of .github/workflows/e2e-kind.yml runs it on two Kubernetes versions, one entry of the matrix each: the pins KIND_NODE_IMAGE and KIND_NODE_IMAGE_PREVIOUS of hack/kind/versions.env (node images by digest; the previous minor version stays within the skew of the kubectl pin, and the charts need Kubernetes 1.30 or later). It starts under the conditions of the probe (the path filter now names the chart test as well), has no secret and a read-only token, and uploads the same allow-list of logs, events, descriptions and diagnostics from .cache/kind after a failure (artifact e2e-kind-charts-<label>), never the kubeconfig, the generated tokens or cookie jars. Its test step has a timeout of its own below that of the job.

Web UI

The UI is React 19, TypeScript, Vite 8 and Mantine 9. It is built into static files in internal/webui/dist and embedded into the myriarchd binary with -tags embedui (make web-build && make build). Run the commands below in web/.

ScriptPurpose
pnpm devVite dev server (port 5173) with a proxy for /api (including the event stream) and /v1 to http://localhost:8080; MYRIARCH_BACKEND overrides the target
pnpm dev:mockDev server without a backend: MSW answers every operation of api/openapi.yaml in the browser
pnpm buildTypecheck and production build into ../internal/webui/dist (contains no mock code)
pnpm build:mockBuild with mock mode into dist-mock/ (static, can be hosted for demos)
pnpm gen:apiRegenerate src/api/schema.d.ts from ../api/openapi.yaml (with --default-non-nullable false)
pnpm typecheck, pnpm lint, pnpm testQuality checks (Vitest, Testing Library, MSW)

make web-install, make web-gen, make web-build and make web-dev wrap the install, gen:api, build and dev scripts. After you change api/openapi.yaml, run pnpm gen:api and commit the generated schema.d.ts; the web gate fails when it is out of date. Run make docs-gen as well: the REST API reference (docs/reference/api) is generated from the same file.

Mock mode

pnpm dev:mock starts around 1,000 deterministic collectors in five fleets, a few unassigned agents and some myriarch-updater agents, releases up to 0.163.0 with breaking changes, three rollouts (running, waiting for approval, completed) and a GitOps status with a warning. Actions change the state in memory, and a timer simulates live events (heartbeats, health changes, rollout progress). The running rollout, the version rollout of the data center fleet, has five targets in the health wait: the rollout page counts down their remaining time, and the mock finishes them when the healthWait of the policy snapshot, two minutes, is over. Like the engine, it does so also while you pause the rollout (or while it waits for an approval), but then it starts nothing new and moves on to no further wave; a target that is in progress waits for the resume.

  • Sign-in: the user name admin, operator or viewer (any password) selects the role, so you can check permissions. The mock starts signed in as admin.
  • Debug hooks in the browser console: window.__myriarchMock.dropStream() simulates a dropped event stream, and window.__myriarchMock.db is the mock state.

Demo: update a version from the UI

Mock mode lets you play through the whole flow “choose in the UI, watch the update live” as operator or admin:

  • A Kubernetes gateway fleet whose Git target is 0.161.0 and whose frame allows >=0.161.0, <=0.162.0 offers the button to update to 0.162.0. The dialog starts the update and the rollout page shows the steps of the single target live: offered, download, verification, installation, restart, health check, probation, succeeded (about two seconds per step). The mock updates one collector only, so that the steps of a single target are easy to follow. The health wait takes one step like the others, and the policy snapshot of that rollout says healthWait: 2s, so the countdown of the step runs from 2 seconds to 0.
  • Rollback variant: if the reason you enter contains the word rollback, the health check fails, the host rolls back (phase “rolled back”) and the rollout pauses. “Retry failed” starts a second attempt that succeeds.
  • Other fleets show the remaining states: a selection made in the UI that is running with a step bar per target, a selection that a narrower Git frame has made invalid (“on hold”), and fleets without a frame in Git, which have no button.
  • To test delayed planning (the dialog waits for the leader), set window.__myriarchMock.db.deferPlanning = true before you submit.

Source layout

src/api/        generated types (schema.d.ts), openapi-fetch client (CSRF, 401), TanStack Query hooks
src/auth/       AuthProvider (GET /auth/config, /auth/me), permissions
src/live/       EventSource connection with backoff, cache patches per server-sent event
src/i18n/       en.json (source of truth) and de.json; same keys, enforced by a test; locale registry in locales.ts
src/theme/      Mantine theme, status colors, WCAG AA contrast variables
src/components/ shared building blocks (badges, pager, dialogs, YAML view and diff, ...)
src/pages/      one directory per page
src/mocks/      MSW handlers, deterministic fixtures, fake EventSource, update simulation (updateSim.ts)

The agent list keeps filter, sort order and page only in the URL, for example /agents?status=online&attr=site.type%3Dedge&sort=-version. Facet counts come from the server (getAgentFacets).

End-to-end tests

scripts/e2e.sh checks the whole system without containers and without network access (a local release mirror and a mock receiver replace the outside world):

scripts/e2e.sh                       # smoke run, 32 simulated collectors, about 6 minutes
E2E_REAL=1 scripts/e2e.sh            # additionally a real supervisor and otelcol-contrib (downloaded into .e2e/real/dl and verified, darwin and linux)
E2E_AGENTS=1000 scripts/e2e.sh       # load run with 1,000 collectors
E2E_KEEP=1 scripts/e2e.sh            # leave myriarchd and the simulator running; clean up with: scripts/e2e.sh --stop

Steps (each prints PASS, FAIL or SKIP with evidence; the exit code is 0 only without FAIL): build; myriarchd dev with the test data in internal/testdata/gitops (GitOps, fleets, releases from fixtures, UI); simulator (all connected, in_sync, fleet distribution); version rollouts through the updater executor (artifacts, canary, approval, waves, real download with a token, 401 without a token); configuration rollout; failure path (pause, rollback, drift, correction); webhook and AWX executors against the mock (signature, limit, completion only through the reported version); release tracking and notification; real supervisor (optional); two replicas with a configuration change on both, kill -9 failover and rejoin without duplicate agents; UI smoke test (optional); declarative enrollment tokens; log check. At the end, everything that was started is stopped again (myriarchd, simulators, supervisor, collector, embedded PostgreSQL).

With more than 100 collectors (the load run), the script raises maxConcurrent of the gateway rollout policy from 1 to 10 in its working copy .e2e/gitops (never in internal/testdata): one gateway at a time, a rollout of the 125 gateways takes about 13 minutes and exceeds E2E_STEP_TIMEOUT, and 10 equals the batchSize of the webhook executor, so the load run also sends batched webhook calls, which the serial smoke run never does.

VariableMeaning
E2E_AGENTSnumber of simulated collectors (default 32)
E2E_PROFILEsimulator profile (default e2e)
E2E_HA=0skip the two-replica step
E2E_UI=0|10 skips the UI smoke test; 1 makes a missing browser or playwright-core a failure (default auto: run when the prerequisites are found, else skip)
E2E_REAL=0|10 switches the real supervisor step off; 1 downloads, verifies and runs it and turns an unsupported platform or a failed download or check into a failure (default auto: it runs only when opampsupervisor and otelcol-contrib are already in E2E_REAL_DIR, and then verifies them as well)
E2E_REAL_DIRdirectory of the supervisor and collector (default .e2e/real; the verified downloads stay in its dl/ subdirectory)
E2E_REAL_BASE_URLdownload location of the upstream release files (default: the GitHub releases of opentelemetry-collector-releases; file:// is accepted for tests)
NO_BUILD=1use the existing binaries in bin/
E2E_KEEP=1leave everything running at the end
E2E_STEP_TIMEOUTseconds per rollout phase (default 600)
E2E_DIRwork directory (default .e2e/)
E2E_PORT_A, E2E_PORT_B, E2E_PORT_PGports of the two replicas (18080, 18081) and of PostgreSQL (54339); more E2E_PORT_* variables exist for the mock, mirror and simulator ports

The work directory .e2e/ (git-ignored) holds the logs myriarchd-a.log, sim.log, mock.log, ui-smoke.log and the result list results.txt. The test uses fixed ports, so do not run two instances, and do not run it while make dev uses the same ports.

UI smoke test (scripts/e2e/ui-smoke.mjs): signs in through the login form, visits every page in the light and dark color scheme, checks console errors, failed API calls and the pagination of the agent list, and checks that the viewer role sees no rollout actions. It needs playwright-core and a Chromium-based browser. playwright-core is a development dependency of web/ (no browser download; cd web && pnpm install --frozen-lockfile provides it), the browser is one that is already installed (Chrome on the GitHub runner images):

(cd web && pnpm install --frozen-lockfile)                # playwright-core; or set E2E_PLAYWRIGHT_NODE_PATH to a folder with node_modules
E2E_BROWSER="/path/to/chromium-based-browser" scripts/e2e.sh

Without the prerequisites the step is skipped with a note (with E2E_UI=1, it fails).

Real supervisor (E2E_REAL): the upstream OpAMP Supervisor and otelcol-contrib of OTELCOL_VERSION (ci/versions.env) are downloaded for darwin and linux on amd64 and arm64 (scripts/lib/real-binaries.sh). Each asset is compared with the SHA-256 that the release publishes next to it, on the download and again on every later run; a mismatch deletes the file and fails the step. The check detects corrupt or changed files, not a compromised release. Test it without network access with ci/tests/e2e-real-download-test.sh.

UI-triggered version updates (make e2e-ui-updates, scripts/e2e-ui-updates.sh) test the operator path of the version update on your machine: myriarchd dev, a local mirror with the real release files of two collector versions, a real supervisor and collector, and myriarch-updater in container mode. The test calls what the UI dialog calls (version options, select version, then the live progress), triggers a real rollback by killing the new collector during the health check and succeeds after a retry. It needs network access once for the release files. Options include E2E_KEEP=1, NO_BUILD=1, E2E_DIR (default .e2e/ui-updates), E2E_GIT_TARGET=git|none and E2E_UI=auto|1|0; the header of the script lists them all. It uses its own ports: 18090 (myriarchd), 54340 (PostgreSQL), 19997 (the mirror), 23133 to 23135 (health checks of the collector, the supervisor and the updater) and 24317 and 24318 (OTLP of the collector).

Host end-to-end test

The installers are tested in staging mode (--root) and with stand-ins by the script tests, and the UI-triggered update runs the updater in container mode. Nothing of that starts a service under systemd, replaces the binary of a running host or takes a host back to nothing. scripts/host-e2e/linux.sh does: on a fresh Ubuntu runner VM of GitHub Actions (amd64 and arm64) it runs install-linux.sh as root, lets the services run under systemd, updates the collector with a real rollback, and removes everything again. It runs in CI only, in .github/workflows/host-e2e.yml: every night, for pull requests that touch the installer, the updater, the templates of a host or the harness, and on a manual run. It is not part of ci-ok.

The job windows of the same workflow does the same for Windows with scripts/host-e2e/windows.ps1; it has its own subsection (Windows) below, and everything that follows in this section about the artifacts, the guard and the structure holds for it as well, unless that subsection says otherwise.

The scenario manual is the installation of Connect a host, step by step:

  1. myriarchd in dev mode with TLS (a throwaway CA, outbound.caFile), the GitOps fixture scripts/host-e2e/gitops (fleet host-e2e, frame >=0.161.0, <=0.162.0, Git target 0.161.0) and a mirror of the real release files of the collector and the supervisor in both versions as artifact source (artifacts.sourceBaseUrl, verified offline against the trusted root of the mirror). The mirror directory comes from scripts/build-mirror-image-crane.sh (COMPONENTS="contrib supervisor") and is served by scripts/host-e2e/httpsd.py; the installer reads the same files as --release-base-url.
  2. An enrollment token through the REST API; the secret goes to a file with mode 0600 and is never printed.
  3. install-linux.sh as root without --insecure: collector 0.161.0, supervisor, and the myriarch-updater built from the checkout (--updater-sha256), the CA of myriarchd as --ca-file, offline Sigstore verification with --trusted-root. The installer reads its templates from a copy of deploy/ in which the updater waits less (health timeout 40 s, check every 2 s, retry after a failed version after 15 s), so that the rollback and the retry take minutes.
  4. The host registers: collector and updater are agents, the fleet host-e2e matches, the configuration is in_sync, the collector is healthy and runs as otelcol, and both units are enabled.
  5. A second run of the installer changes nothing (no restart of the supervisor, the collector or the updater).
  6. A collector archive that the release did not sign (the checksum file fits the changed file, only the Sigstore bundle can tell) is refused with sigstore_verification_failed; the old version keeps running.
  7. The update to 0.162.0 as the UI starts it (select-version): a helper kills the new collector during the health check, the updater rolls back by itself, the rollout pauses.
  8. retry-failed: the update succeeds (download, verification, installation, restart, health check, health wait).
  9. install-linux.sh --uninstall --purge, and nothing is left: no file, user, unit, process or listener of the installation.

The scenario one-command is the installation from the server (ADR-0046, Connect a host): the command that the server renders, run unchanged.

  1. The installer bundle. A commit has no signed release, so the harness builds the bundle from the checkout in the layout of the release archive of the updater (myriarch-updater_<VERSION>_linux_<arch>.tar.gz: deploy/scripts/install-linux.sh, the templates and units, and the myriarch-updater built from the commit; he_bundle_build). With HOST_E2E_RELEASE_TAG (the input release_tag of a manual run, for example v0.2.0-rc.1) the harness downloads the published archive of that release instead, verifies checksums.txt against its Sigstore bundle with the identity of the release workflow at that tag (the updater of the checkout does it, the identity is fixed in he_bundle_fetch_release), and checks the archive against its line. Only this scenario runs then; it is how the board accepts a release candidate on real runners.
  2. myriarchd as in manual, plus --install-bundle-dir (the development server serves the archives of a directory, source: directory) and install.platforms set to the platform of the runner.
  3. An enrollment token through the API at run time (a file with mode 0600, masked in the log with ::add-mask::, never printed).
  4. The command, rendered with POST /api/v1/install/commands the way the dialog does it: the variant token_inline, the platform of the runner, the fleet host-e2e, the site edge/host-e2e-01 and the CA file of the throwaway CA. The response holds no token; the harness puts the token into a copy of the command (a file with mode 0600, deleted right after the run, never uploaded).
  5. That command, run unchanged by the user of the test: it starts the privileged shell itself, writes the token to a private directory below /root, downloads the bundle from the server, checks the pinned SHA-256, unpacks it and starts install-linux.sh --server. The installer asks for the plan, downloads collector and supervisor from the server with install grants and verifies them with the myriarch-updater of the bundle against the upstream identity and, over TUF, the public trusted root. The test checks the log, both units, the files, and that the private directory is gone.
  6. The host registers: a healthy collector in the fleet host-e2e at the target version of the fleet, in_sync, the updater linked. Then the waits of the installed updater are shortened in /etc/myriarch/updater.yaml (the same values as in manual, through the same scripts/host-e2e/tune-updater.py), so that the rollback and the retry take minutes.
  7. A configuration rollout: the blueprint of the GitOps fixture changes, POST /gitops/actions/sync, the configuration rollout completes, the collector is in_sync again and reports the changed configuration.
  8. The update to 0.162.0 with a failure, the rollback by the updater and the retry, as in steps 7 and 8 of manual (he_update_with_rollback).
  9. install-linux.sh --uninstall --purge, nothing is left (also no working directory of the command below /root).

Nothing in the log or the upload may hold the token: the harness masks it, the installer and the command never print it, and the upload takes the allow-list of files only, never the token file, command.sh or commands-response.json. The test of the script itself without a host is internal/install/installer_server_test.go: it runs install-linux.sh --server in staging mode against a test server and covers the options of the rendered commands, the limits of the plan and every error code of the server mode.

Windows

scripts/host-e2e/windows.ps1 one-command is the same scenario for a Windows host: install-windows.ps1 as an administrator, the two Windows services (otelcol-supervisor under its virtual account NT SERVICE\otelcol-supervisor, myriarch-updater as LocalSystem), the registry value with the enrollment token, the ACLs below C:\Program Files\Myriarch and C:\ProgramData\Myriarch, an update with a rollback, and the uninstallation. It runs in the job windows of .github/workflows/host-e2e.yml on windows-2025 (amd64), twice at the same time: in Windows PowerShell 5.1 (shell: powershell) and in PowerShell 7 (shell: pwsh). The PowerShell of the run is the administrator session in which the command of the server is pasted and the one that starts the uninstaller; the installer itself always runs in the powershell.exe that the command starts. The runner is pinned to an image and not to windows-latest, like the Linux job to ubuntu-24.04: services, ACLs and registry are the subject of the test, and the label moves only in a commit. The job needs only the read-only token, has no secret and never runs on a self-hosted runner; the workflow builds the three binaries with the tag devpg (the same flags as make build, with the extension .exe).

The steps are those of one-command above, with the differences that the platform makes:

  1. The bundle is a zip (myriarch-updater_<VERSION>_windows_amd64.zip: deploy\scripts\install-windows.ps1, the templates, myriarch-updater.exe), built by New-BundleFromCheckout; with HOST_E2E_RELEASE_TAG the published zip, after the keyless check of checksums.txt.
  2. The mirror is built by the script itself (New-Mirror: download, SHA-256 and the offline Sigstore check of the Windows files of both versions with myriarch-updater verify-bundle), the fleet is windows-hosts (scripts/host-e2e/gitops-windows/fleets, the blueprint and the policies are those of the Linux fixture), the site edge/host-e2e-win-01.
  3. The server certificate is checked by the installer against the certificate store of the machine (the command of the server has no CA option on Windows), so the script puts the throwaway CA into Cert:\LocalMachine\Root for the duration of the scenario and removes it again at the end (Add-CaTrust, Remove-CaTrust); the final check says that it is gone.
  4. The command is run unchanged in an administrator session: the script evaluates the text of the rendered command with Invoke-Expression in a new process of the PowerShell that runs the script, the way a person pastes it. The test checks the log, both services (state, start type, account), the registry key of the supervisor (only SYSTEM and the administrators), that no ACL on the data directories lets users, everyone or authenticated users in, that the value of the service environment exists (without printing it), and that the working directory below C:\Program Files is gone.
  5. The fault injector is scripts/host-e2e/killer.ps1: the updater activates a version on Windows by copying it to collector\bin\otelcol-contrib.exe, so the path of the process never names the version; the helper kills the collector while the active copy has the size of the new version’s binary. After the rollback current-version and the active copy are the old version again.
  6. Uninstall: install-windows.ps1 -Uninstall -Purge, then the proof that no service, no file, no registry key below HKLM\SYSTEM\CurrentControlSet\Services, no process, no listener on port 13133 or 13134 and no working directory of the command is left.

The guard of the script: CI=true, HOST_E2E=1, Windows, an elevated session, RUNNER_ENVIRONMENT=github-hosted inside GitHub Actions, and a host without a trace of an installation (no Myriarch directory, no service of either method of installing the programs, no registry key, process or listener). Windows has no sudo, so the single gate of the Linux script does not exist here; every call that runs the installer goes through Invoke-HostScript, which checks the guard again. A failed run uploads host-e2e-windows-<shell>: results.txt, the logs of myriarchd and the installer runs, diag\status-*.txt (sc.exe query, qc, qfailure), diag\events-*.txt (the Application event log of both services), diag\files.txt, diag\acl.txt (never the registry value of the service environment), diag\updater-log.txt and the snapshots of the API; the scrubber and the allow-list are those of the Linux scenario, written in PowerShell (Invoke-Scrub, Export-Artifacts).

ci/tests/host-e2e-test.sh runs what needs no Windows host, under pwsh (ci/install-tools.sh pwsh): the parse of both files, the helpers and the guard (ci/tests/host-e2e-windows.ps1), and myrctl validate --strict of the Windows fixture. The scenario has run only on a runner; the first green run decides that the Windows support level can leave “experimental” (the documentation pass sets it).

What the scripts refuse and protect. The scripts install a service user, units and files below /etc, /opt and /var. They refuse to run unless CI=true and HOST_E2E=1 are set (the workflow sets the second one on the step that runs a scenario only), on Linux, as a user other than root (the embedded PostgreSQL refuses root; the harness uses sudo for every change of the host, through one function that checks the guard again, and a test fails when a script calls it anywhere else), with passwordless sudo, and on a host without any trace of an installation; the check for that comes first, and the reset at the end of a scenario runs only after it, so an installation of a developer is never removed. Do not run them on your machine and do not move the workflow to a self-hosted runner that is not recreated for each job. The throwaway CA is trusted only by the process of the installer (CURL_CA_BUNDLE); the trust store of the host is untouched.

The test for the one function (ci/tests/host-e2e-test.sh) is a static check of every shell or Python file and every executable in scripts/host-e2e/: the word sudo may appear on a comment line and in two places of lib.sh, the probe for passwordless sudo and he_sudo itself, and nowhere else. It does not try to find out where a command stands, so a call behind env, xargs, a wrapper function of the harness, bash -c or an assignment is found like a call at the start of a line, whatever its options are, and a message names he_sudo instead of sudo. A new place that needs root is a reviewed change of that test. The test proves the net by planting each of these forms in a copy of the scripts and requiring the report.

Structure. scripts/host-e2e/lib.sh is the harness: the guard, the results, the CA, the https file server, the mirror, the server start (he_server_start, which passes extra flags on to myriarchd dev), the REST helper (he_api), the host reset (he_reset_host: uninstall and the proof that nothing is left) and the collection of the artifacts. A scenario is a function in a script of its own (linux.sh: scenario_manual) that he_run_scenario runs; the workflow calls it by name (scripts/host-e2e/linux.sh manual), and every scenario ends with he_reset_host. A further scenario is a function, a call in the workflow, and nothing else (one-command was added that way): its files go to .cache/host-e2e/upload/<scenario>/, which the upload step already covers. State that the server derives is read after he_settle, never at a fixed moment: the engine of the rollouts derives the phase and the steps of a target on its tick, up to rollouts.tickInterval after the last report of the updater, and the API returns the stored copy (Target progress); the package status of an updater is a message of its own. he_settle NAME TIMEOUT WANTED GETTER... polls the getter (he_target_phase, he_package_status) and records PASS, or FAIL with the last value, and the checks that read the same state follow it.

Artifacts. The work directory is .cache/host-e2e (HOST_E2E_DIR): the CA, the mirror, and for each scenario its logs, results and work files (the token file, the cookie jar and the database are in there). A failed run uploads only .cache/host-e2e/upload/ (seven days): the harness copies an allow-list of files into it (results, the logs of myriarchd, the mirror and the installer runs, the journal and the state of both services, supervisor.yaml and updater.yaml, snapshots of the API), scrubs them of tokens, bearer credentials, DSNs, session cookies and private keys, and removes the directory if the token or a key of the run, or anything shaped like one, is still in it. myriarch.env and the token file of the host are never copied. After a failure the harness collects them twice, right after the diagnostics and again at the end, because a runner that cancels a step kills the script a few seconds later. The step that runs the scenario has a timeout of its own, below that of the job: a job that runs out of time is cancelled, and the upload, which runs only for a failed job, is skipped for a cancelled one, whereas a step that runs out of time counts as failed. ci/tests/workflow-artifacts-test.sh holds the patterns of the upload step to this, and the timeouts of the steps that run a scenario to at least five minutes below that of the job, with copies of the workflow that are broken on purpose.

VariableMeaning
CI, HOST_E2Ethe guard: both are required (true and 1)
HOST_E2E_DIRwork directory (default .cache/host-e2e)
HOST_E2E_BIN_DIRthe directory with myriarchd, myriarch-updater and myrctl (default bin/; make build makes them)
HOST_E2E_DEPLOY_DIRthe deploy/ tree with the installer and the templates (default the checkout)
HOST_E2E_RELEASE_TAGfor one-command: install from the published installer bundle of this release (vX.Y.Z or vX.Y.Z-rc.N) instead of a bundle built from the checkout
HOST_E2E_PORT_SERVER, _PG, _MIRROR, _EVILports of myriarchd, its PostgreSQL, the mirror and the tampered mirror (18443, 54341, 19443, 19444)

A failed run: download the artifact host-e2e-linux-<runner> (Windows: host-e2e-windows-<shell>) of the run; results.txt lists every check, diag/journal-*.txt the journal of the services, install-*.log the output of each installer run, myriarchd.log the server, mirror.log the requests to the mirror. The harness runs on a Mac too as far as it needs no host (ci/tests/host-e2e-test.sh covers the guard with stand-ins for uname, id, sudo and systemctl, the scrubber and the allow-list, the CA, the file server and the fixture); the scenario itself only on a runner. The scripts are listed in AGENTS.md among the tests that must not run in parallel.

Database migrations and upgrade notes

The schema is created and changed by the numbered SQL files in internal/store/migrations (goose format). The server applies them at start (database.migrateOnStart), and myriarchd migrate applies them from the database settings alone. How a database and a binary fit together, and what the server does when they do not, is specified in Server behaviour (section “Start, migrations and schema compatibility”) and decided in ADR-0036.

Add a migration with these steps:

  1. Create NNNNN_description.sql with the next number (no gaps), the SPDX header, a header comment, -- +goose Up and -- +goose Down. Never edit a migration that may have been applied anywhere; a mistake is fixed by a new migration.

  2. State the effect in the header comment with two lines that agree with each other. These are the only two forms, and TestMigrationHeaders rejects a text that starts with neither of them and any other combination. For a change that the previous version keeps working with:

    -- Rolling update: expand only. Older replicas ignore the new column.
    -- Compatibility level: unchanged.
    

    For a change that binaries below some level cannot work with (it drops, renames or tightens something they use, or changes what stored data means), the text starts with not compatible, and says what stops working:

    -- Rolling update: not compatible, replicas of the previous version can no longer store x until they are updated.
    -- Compatibility level: raised to 00009.
    

    The number is the oldest migration whose binaries still work on the new schema: greater than the previous level and at most the number of the migration itself. Up ends with UPDATE schema_compat SET min_binary_schema = 9, updated_at = now(); and Down sets the previous value again. Binaries below that level refuse to start on the database; binaries at or above it start. Split a destructive change into two releases to keep rollbacks possible: add the new object in one release (expand only), drop the old one in a later release, and raise the level to the number of the migration that added the new object. The migration that drops it is not compatible for the releases before the first one: its text names them and says that the previous release keeps working.

  3. Add a test for the change itself (see TestBootstrapAdminMigration and TestUIVersionSelectionMigration), and raise the table count in TestSchema when the migration adds a table. TestMigrationNumbersAreConsecutive, TestMigrationHeaders and TestCompatibilityLevelFollowsTheHeaders run for every migration from 00006 on and fail when the numbers have a gap, a header line is missing or wrong, the two lines contradict each other (for example not compatible with unchanged), the header and the effect on schema_compat differ, or Down does not restore the previous level.

  4. Describe every consequence that a user must know before the upgrade in the changelog (next paragraph), and change the design documents in the same pull request.

Upgrade notes in the changelog. The section ## Unreleased of CHANGELOG.md uses these subsections in this order, and only the ones that are needed: ### Upgrade notes, ### Added, ### Changed, ### Fixed, ### Security, ### Documentation. “Upgrade notes” holds every change of behaviour that a user of the previous release must know before the upgrade, each with what to do about it: a changed default, a removed or renamed key, a migration that raises the compatibility level, a higher minimum version of a dependency. A pull request appends its own bullets and does not rewrite the bullets of others; the release preparation consolidates the notes.

Database tests against an existing PostgreSQL

The Go tests that need a database start an embedded PostgreSQL per test program (internal/testutil/pgtest). It downloads its binaries on first use and refuses to start as root. In CI runners without internet access, or as root, set PGTEST_DSN to an existing server instead (a role with CREATEDB; a superuser is recommended):

export PGTEST_DSN='postgres://postgres:secret@127.0.0.1:5432/postgres?sslmode=disable'
ulimit -n 10240 && go test -race -count=1 ./...

Each test program creates its own template database myriarch_tmpl_<pid>_<random> (parallel test programs share the server), migrates it and clones it per test. User, password and parameters of the DSN are kept; only the database name is replaced. At the end, the template and the test databases are dropped (an aborted run leaves myriarch_tmpl_* and t_* behind). The tests of internal/devpg, which start an embedded PostgreSQL themselves, are skipped in this mode. PGTEST_SKIP=1 skips all database tests. When several packages run in parallel, give the server a max_connections of at least 300; the embedded server uses 100 per test program.

Database environments (TLS, collation)

Two environment variables put the embedded server (not an external one: there the DSN decides) into environments that the default run does not cover; everything else is unchanged:

VariableEffect
PGTEST_TLS=1The server accepts TLS with a freshly generated certificate for 127.0.0.1 and the tests connect with sslmode=verify-full.
PGTEST_COLLATION=<locale>The template database, and with it every test database, is created with this collation, for example en_US.UTF-8 (a locale of the operating system) or icu:en-US (needs a server with ICU). A locale the server does not know fails the run.
PGTEST_TLS=1 go test -race -count=1 ./internal/store/... ./internal/leader/... ./internal/eventbus/...
PGTEST_COLLATION=en_US.UTF-8 go test -race -count=1 ./internal/store/... ./internal/leader/... ./internal/eventbus/...

CI runs both in the job db-matrix (ubuntu-24.04, embedded PostgreSQL). TestDatabaseEnvironment in internal/store/migrations fails when a variable is set but the tests do not run in that environment. The ports are random, there are no fixed ports. pgtest.StartProxy is a TCP proxy that can stop forwarding without closing the connection; the failover test of internal/leader/pglock uses it.

Documentation

The documentation lives in docs/ and is licensed under CC-BY-4.0 (ADR-0018); it is written in English (ADR-0020). The structure follows the Diátaxis framework (tutorials, how-to guides, concepts and reference), plus operations, security and the project documents. The site structure follows these sections:

SectionDiátaxis groupContentWeight
getting-started/Tutorialsfrom source to a running system10
tasks/How-to guidesstep-by-step guides20
concepts/Conceptshow the system works30
reference/ReferenceGitOps schema, generated command-line reference, REST contract, Helm chart40
operations/Operationsauthentication, production checklist, troubleshooting50
security/Securitysecurity model60
architecture.md, adr/, design/, development.md, releasing.mdProjectarchitecture, decision records, design contracts, this guide, versioning and release tags70, 80, 90, 100, 110

The directory tasks/ keeps its name, because links and the URLs derived from it should stay stable; its title is “How-to guides”. The website adds pages that exist only there (placeholders for pages that are still being written, the project landing page and the FAQ) and renders the Helm chart README, the GitOps template README and the brand README as pages (Website).

  • Every Markdown file under docs/ starts with YAML front matter with exactly these keys in this order: title and description as double-quoted strings and weight as an integer:

    ---
    title: "Fleets"
    weight: 40
    description: "How fleets select agents and how the target version is chosen."
    ---
    

    The description is one sentence that ends with a period. The body does not repeat the title as an H1; sections start at H2.

  • Decision records (docs/adr/NNNN-*.md) and the template keep the MADR layout without front matter.

  • Section landing pages are named _index.md. docs/adr/README.md and docs/design/README.md keep their names so that existing links keep working.

  • Pages in a section use the weights 10, 20, 30 and so on, in reading order.

  • Links are relative and end in .md (for example ../concepts/fleets.md); files outside docs/ are linked relatively too. Do not use absolute /path links. Link to anchors only inside the same file.

  • Use neutral examples (ADR-0022): edge sites, data centers, cloud and Kubernetes; the quickstart fleets linux-hosts, windows-hosts and k8s-gateway; hosts under example.com or .test and addresses from RFC 5737.

  • Root files (README.md, CONTRIBUTING.md, …) and .github/ files have no front matter.

Generated references

Six files are generated and must not be edited by hand:

  • docs/reference/cli/ comes from the cobra command trees of the four binaries.
  • docs/reference/api/ comes from the OpenAPI contract api/openapi.yaml (internal package internal/apidoc): an index with the conventions, security schemes, the table of permissions with their lowest role (resolved from internal/authz) and the error format, one page per tag with every operation, and a page with the data types.
  • docs/reference/configuration.md comes from the configuration struct in internal/config (types, the defaults of Default() and DevDefaults(), the doc comments as descriptions) and from the loader in internal/configload.
  • docs/reference/environment-variables.md comes from the environment overrides of the loader, from a registry of the other environment reads in internal/refdoc/env.go and from the deployment templates under deploy/supervisor and deploy/updater.
  • docs/reference/metrics.md comes from the metric definitions in the Go sources.
  • deploy/helm/myriarch/values.schema.json, the values schema of the Helm chart, comes from the hand-written deploy/helm/myriarch/values.schema.source.yaml and, for the block config of the values, from the configuration struct in internal/config (keys, types, nesting and the doc comments as descriptions).

The last three pages and the values schema are generated by internal/refdoc, which only tests import.

After you add or change a command, flag or help text, a configuration key, an environment read, a metric or a value of the Helm chart, or change the contract:

make docs-gen          # regenerates all six (TestCLIReference with CLIDOC_UPDATE=1, TestAPIReference with APIDOC_UPDATE=1, TestReferencePages and TestChartValuesSchema with REFDOC_UPDATE=1)

TestCLIReference in cmd/myriarchd, TestAPIReference in internal/apidoc and TestReferencePages and TestChartValuesSchema in internal/refdoc fail when the committed files differ from the generated output (without the variable they only compare), and ci/check-docs-gen.sh makes the same comparison for the docs gate. After a change of the contract also run pnpm gen:api in web/ (the types of the web UI come from the same file).

The generators for the configuration, environment and metrics pages and for the values schema rely on rules that tests enforce:

  • Doc comments of the configuration (TestConfigCommentsAreDocumentation). The comments in internal/config/config.go are the descriptions on the page, so write them for the reader of the page. (1) Every field with a YAML key has a doc comment, the comment block above the field; a field whose type is a named struct of the package may rely on the doc comment of the type. A comment on the line of the field does not count. (2) The comment starts with the Go name of the field, as Go convention asks; the generator replaces that first word by the YAML key. (3) The text names no Go identifier of the package, neither a field or type name with an inner capital letter (CertFile, DownloadTokenTTL, ArtifactsConfig) nor a Go path (TLS.TerminatedUpstream): write the YAML key or path in backticks (server.tls.terminatedUpstream). Acronyms (TLS, OIDC) and ordinary words are fine. A duration in Go syntax has no unit for days, so write “30 days” and let the Default column show 720h. The generator turns a mention of a file below docs/ into a link, escapes characters that Markdown or Hugo would take for markup (<base> would disappear as raw HTML) outside of code spans, and refuses unpaired backticks.
  • Environment reads (TestEveryEnvironmentReadIsDocumented). A string literal passed to os.Getenv, os.LookupEnv or a function called getenv, and every constant of internal/brand whose name ends in Env, must have an entry in the registry envReads in internal/refdoc/env.go (or, for a variable of the deployment templates, in templateVarMeaning). Reads in internal/testutil are exempt. The overrides of configuration keys need no entry: they come from the loader.
  • Metric definitions (ScanMetrics). Every metric is a literal call of prometheus.NewCounter, NewCounterVec, NewGauge, NewGaugeVec, NewHistogram, NewHistogramVec, NewSummary or NewSummaryVec with a prometheus.<Kind>Opts literal that sets only Name, Help and Buckets (Objectives for a summary), all as literals. Help is a sentence that ends in a period, a histogram sets its Buckets, and the label names of a vector are a []string literal. Namespace, Subsystem, ConstLabels, promauto, NewDesc, NewGaugeFunc and the other constructors are refused with the file and line; extend internal/refdoc in the same change if you need one. cmd/myriarchd compares the metrics of a running server with the page.
  • Golden tests. TestGoldenConfiguration, TestGoldenMetrics and TestGoldenChartSchema compare the output for a small structure, for internal/refdoc/testdata/metrics/ok and for testdata/chartschema/source.yaml with testdata/golden; REFDOC_UPDATE=1 go test ./internal/refdoc rewrites them (and the three reference pages and the values schema) and you review the diff.
  • The values schema of the Helm chart. A new value of the chart goes into values.yaml, the templates and values.schema.source.yaml, which is JSON Schema (draft 2020-12) written as YAML; the placeholder x-generated: internal/config at properties.config is replaced by the generated block. The schema is strict: every object is closed (additionalProperties: false) unless it holds a Kubernetes object that the chart passes on, and each such open object is listed with its reason in openObjects of internal/updater/deploy_chart_schema_test.go. The keys of the configuration that the chart does not offer (secrets as values, the development mode, the settings that the chart generates) are excluded by path in chartConfigExclusions (internal/refdoc/chartschema_production_test.go); the test of internal/updater repeats the list on purpose, and an exclusion that matches no setting fails the generation. TestChartSchemaOffersNoSecret fails for a setting of the configuration that can hold text (a string, a list of strings, a map of strings or of lists of strings) and names a token, secret, password, key, credential, passphrase, bearer token or DSN, unless it is a file, a directory or a prefix, so a new setting of that kind is excluded or written as a file setting deliberately; a setting that only has such a word in its name (executors.secretEnvHosts) is listed in chartSecretNamedSettings with the reason. TestChartSchemaSecretCheckSeesEveryShape runs a setting of every shape through the generator and the check, so a check that looks at strings only fails. deploy_chart_schema_test.go checks the schema without Helm (its stand-in validator implements the keywords the schema uses and reports any other) for strictness, coverage of values.yaml and of the values the templates read, the match of the block with internal/config, the shipped example values and a table of refused values; ci/tests/helm-messages-test.sh checks it with the real Helm. Helm reads YAML 1.1, in which the plain words y, n, yes, no, on and off are booleans: quote them in example and test values.

The API generator renders a deliberately small subset of OpenAPI: the keywords type, format, enum, default, example, description, properties, required, items, additionalProperties, the numeric, length and item limits, uniqueItems, pattern, $ref to components, deprecated, readOnly and writeOnly, the contract info (title, version, summary, description, contact, license) and exactly one server whose URL is the base path. Validate refuses everything else (allOf, oneOf, anyOf, not, title, a type array, an inline nested object, a property description of more than one paragraph, a description or required where the page has no place for it, an external $ref, more than one server) and names the JSON pointer. A keyword is either rendered in every place where Validate accepts it or refused there, never dropped; TestEveryKeywordIsRendered sets every keyword in every place and checks the output. If the contract needs more, extend the generator and its tests in the same change instead of loosening the check. Every operation names its permission in x-permission; the generator fails for a permission that internal/authz does not know (public and custom are the exceptions). The golden test TestGolden compares the output for internal/apidoc/testdata/mini.yaml with testdata/golden (APIDOC_UPDATE=1 go test -run TestGolden ./internal/apidoc rewrites them).

The field reference docs/reference/gitops-schema.md is maintained by hand and checked by TestGitOpsSchemaReference in internal/gitops: every kind, field and option of the GitOps format must appear there, so a new field fails the test until it is documented.

Checks

make docs-check        # generated reference up to date, links resolve, front matter is valid

make docs-check runs ci/check-docs-gen.sh and scripts/check-docs.py (subcommands links and front-matter). links resolves every relative Markdown link and checks the anchors of links into the same file; front-matter checks the key set, the order and the types. External links are not checked offline. The links check skips website/ (the site content uses site-absolute links, which Hugo and lychee validate); front-matter --site applies the rules of the site content to it. make check-local runs the same checks as the docs gate, and the language gate covers the English text of docs/, the root files and .github/.

Decision records

Significant decisions are recorded as ADRs in docs/adr/ (process in docs/adr/README.md). Accepted records are not rewritten: a change of mind is a new record that supersedes the old one. Editorial fixes (links, typos) are allowed. Design contracts in docs/design/ are normative: code that deviates from them is a bug in the code or a reason to update the contract in the same pull request.

Website

The project website (myriarch.dev, ADR-0031) is built with Hugo extended and the Docsy theme from the sources in website/ and from the Markdown files of the repository that it mounts. It is a static site: the output is the directory website/public. .github/workflows/website.yml deploys it to production on pushes to main and builds protected previews for pull requests (see website/README.md, “Deployment”); the make targets below never deploy anything. The release status on the landing page and the version in the structured data of the home page come from website/data/release.yaml (Releasing).

Commands

make website            # production build into website/public (hugo --panicOnWarning)
make website-serve      # Hugo development server on http://127.0.0.1:1313 with live reload
make website-preview    # production build served as static files on http://127.0.0.1:1313
make website-check      # the whole website gate: build and every check

All four call ci/website.sh, which puts .cache/bin on the PATH, installs the pinned tools (hugo, sass, lychee), runs pnpm install --frozen-lockfile in website/ and keeps Hugo’s module cache in .cache/hugo. The first run needs network access (Go proxy for the theme, npm registry, release downloads); later runs use the caches. WEBSITE_PORT changes the port (never 8080 or the ports of the end-to-end tests; ci/website.sh refuses them), WEBSITE_BASEURL overrides the base URL for a preview on another host, and WEBSITE_ENV=preview builds the preview environment (noindex, Disallow: /). The build needs Go (Hugo fetches the Docsy theme as a Go module), Node.js and pnpm; make website-check and the browser checks need Node.js 22 or newer (markdownlint-cli2) and, for the latter, a Chrome (CHROME selects the executable).

docs/ is the content of /docs/, never copied: website/config/_default/module.yaml mounts it. The decision records (docs/adr/NNNN-*.md) and the root files ROADMAP.md, CHANGELOG.md, CONTRIBUTING.md, GOVERNANCE.md, SECURITY.md, CODE_OF_CONDUCT.md, MAINTAINERS.md, ADOPTERS.md and CONTRIBUTOR_LADDER.md are rendered through content adapters (website/content/**/_content.gotmpl, data in website/data/repo_pages.yaml): the H1 line becomes the title and the file is otherwise shown as it is. URLs mirror the repository paths (docs/concepts/gitops.md is /docs/concepts/gitops/, ROADMAP.md is /project/roadmap/). api/openapi.yaml (offered for download; the REST API reference in docs/reference/api is generated from it) and the brand files of assets/brand are mounted as well. Pages that exist only for the website live in website/content (front matter checked by scripts/check-docs.py front-matter --site).

A link render hook maps the links of the Markdown files: a relative link is resolved against the repository path of the page and becomes a link to the page that renders the target, or to the file on GitHub when the site has no page for it. Write the links as you do for GitHub (relative, ending in .md, with the anchor). Do not write a double opening brace followed by < or % in a Markdown file that the site renders: Hugo takes it for a shortcode, also inside code blocks, and the build fails. Site-absolute links (/docs/) are only allowed in website/content; a link to a page that does not exist fails the build. Images must be resources of the site: an image that cannot be resolved fails the build, because an image from another host would be a third-party request.

Some files of the theme are replaced by modified copies in website/layouts and website/assets (the head with the self-hosted search script, the canonical link and the structured data, the Open Graph and Twitter cards, noindex on placeholders, the favicons, the “edit this page” links, Mermaid, the footer, the 404 page, and assets/js/offline-search.js, which loads the search index on the first use of a search box instead of on every page load). website/overrides.txt pins the SHA-256 of the upstream file of each one; after a Docsy update website/scripts/check-overrides.py shows which upstream file changed, and it fails when a file of website/layouts or website/assets shadows a theme file without a pin.

Adding a page and placeholders

A page that belongs to the documentation is a Markdown file in docs/ with the usual front matter; it appears in the site and in the sidebar by its weight. A page that exists only on the website goes to website/content/ with title and description (weight is optional). A page that a later change will write gets a placeholder at the website path of the page it will replace:

---
title: "Upgrade myriarch"
weight: 50
description: "Upgrade the server, the database schema and the updaters without downtime."
params:
  placeholder:
    tracking: "operations-manual"
    replaces: "docs/operations/upgrade.md"
    planned: "v0.3.0"
---

The body is a single call of the placeholder shortcode (copy an existing placeholder page), which renders the notice and links to the closest existing pages. The file is website/content/docs/operations/upgrade.md. The tracking value is never rendered; it is a lowercase topic slug (operations-manual) or a GitHub issue reference, which must be quoted ("#123"; an unquoted # starts a YAML comment). An optional planned: "v0.3.0" under placeholder makes the notice say that the page is planned for that release; without it the notice makes no promise. When the real page is added to docs/, the placeholder must be deleted in the same change: website/scripts/check-placeholders.py fails otherwise, so two pages for one URL cannot exist. In production a placeholder carries the robots meta noindex, follow and is not listed in the sitemap, so search engines do not index the notice but follow its links. The real page is indexed and listed when it replaces the placeholder (check-indexing.py and check-seo.py check both).

Checks

make website-check builds the site and runs, in this order: hugo mod verify; lychee over website/public (offline, with fragments; the config is website/lychee.toml); markdownlint (website/.markdownlint-cli2.jsonc) over docs/, the mounted root files and website/content; scripts/check-docs.py front-matter --site; every website/scripts/check-*.py over the built site (check-third-party.py: no resource from another host in any page, style sheet or script; check-local-paths.py: no local path of the build machine; check-repo-links.py: every link into the GitHub repository (blob, tree, edit) names an existing path; check-overrides.py; check-placeholders.py; check-notices.py: every shipped dependency has a notice in website/data/third_party.yaml and a published license text under /licenses/; check-seo.py: the canonical links, the structured data (JSON-LD), the Open Graph and Twitter cards and the publication dates, llms.txt, the crawler policy of robots.txt and the 404 page, in production and in a preview, see website/README.md, “SEO and GEO”); and the unit tests of those checks (python3 -I -m unittest discover -s website/scripts -p 'test_*.py', also part of make ci-test as ci/tests/website-test.sh). Add a check by adding website/scripts/check-<name>.py; the runner picks it up. A check receives the built site as its argument and prints paths and counts only; the findings of check-seo.py also quote the offending value (a title, a link, a JSON-LD property), because the site is public and the value makes the finding actionable.

Two checks need a browser and are not part of the gate:

make website-preview &                      # production build on 127.0.0.1:1313
node website/scripts/check-requests.mjs     # every page of request-pages.txt in light and dark: only same-origin requests,
                                            # no CSP violation or console error, the search finds results, diagrams render,
                                            # the search index is requested by the first use of a search box, not by a page load,
                                            # and an index that failed to load (network error, 404) is requested again by the next query

and Lighthouse (version 13, needs Node.js 22.19 or newer, therefore not in the lockfile):

pnpm dlx lighthouse@13.5.0 http://127.0.0.1:1313/ --output=json --output-path=/tmp/lh-home.json \
  --chrome-flags="--headless=new" --only-categories=performance,accessibility,best-practices,seo
python3 -I website/scripts/lighthouse-scores.py --min 90 /tmp/lh-home.json

The server behind make website-preview (website/scripts/preview-server.py) behaves like a static host: directory index files, /404.html with the 404 status and gzip for text files. Without compression the style sheet (about 0.4 MB) and, once the search is used, the search index (about 1.2 MB of JSON) would be several times larger on the wire than for a visitor. The search index is not part of the page load (website/README.md, “Search”), so a Lighthouse run does not request it. Stop the preview server afterwards. Add pages to website/scripts/request-pages.txt when you add a page with its own scripts.

One caveat for Lighthouse against this server: it answers in about a millisecond, and the simulated throttling of the default mode then puts a long task of a script before the first paint, where Total Blocking Time does not count it. The script that loaded the search index on every page scored 96 on mobile with a Total Blocking Time of 0 ms here (the task ended at 1.8 s, the first paint came at 2.1 s), although PageSpeed Insights measured 440 to 490 ms on the live site. With --throttling-method=devtools (the browser applies the throttling) the same task shows as about 400 ms (score 89), and so it does in the default mode behind a proxy that delays every response by 60 ms. Check such a change with one of the two, not only with the default run.

The REST API reference is made of static pages without scripts of their own; the data types page is long (about 150 types), which is why it is a separate page. check-requests.mjs checks that a tag page renders its tables and that the site search finds the operations (approveRolloutWave) and permissions (rollouts:operate) of the contract, and the motion of the mark on the landing page (about a minute, in light and dark): the rollout plays and ends on identity transforms, the unchanged mark is held and a second cycle starts after the dots retract, a click on the mark (or Enter or Space on the focused mark) pauses the loop and shows the static mark, the next one resumes at the retraction that ends a cycle (the dots shrink from the full mark and the ring does not grow again), aria-pressed follows, the focus ring shows for keyboard focus and not after a mouse click, nothing runs while the hero is out of view or the document is hidden, with reduced motion nothing animates and the mark has no role and is not focusable, and without JavaScript the rollout plays once and the mark is a plain image.

Privacy rules

The site makes no request to a third party when a page loads, sets no cookies and has no analytics. Do not add a CDN script, a web font from a provider, an embedded frame, a tracking pixel or a remote image; the checks above fail on them, and production pages carry a Content Security Policy (website/layouts/_partials/csp.html) that allows only the site itself. Third-party code comes from website/package.json (exact versions, pnpm-lock.yaml) and is served from the site; a new component needs a notice in website/data/third_party.yaml. The only value stored in the browser is the light or dark choice (localStorage); the page /legal/privacy/ says so. A new feature that needs more has to be decided in an ADR first.

Hosting

The first host is Vercel, but the site is plain static files without functions or host configuration, so it can move to Netlify or GitHub Pages. Build it with the project’s own tooling (make website-check) and deploy the directory website/public; do not use the build image of the host, which has no Dart Sass and would run repository code. Headers such as HSTS and frame-ancestors cannot be set in a meta tag and belong to the deployment. website/README.md has the details.