Skip to content

The toolchain ​

Tally is one Go module, github.com/b42labs/tally. Six binaries live under cmd/: tally-reporting, tally-reporting-admin, tally-engine, tally-openstack-collector, tally-openstack-simulator and tally-vertical-slice. Everything they share lives under internal/. The binding stack decisions are in section 1 of roadmap/00-conventions.md. This page says which tool the repository runs each of them with, and what to run again after a change.

Go ​

Two lines of go.mod name a Go version, and they say different things. The go 1.26.0 line is the language version the module is written against and the minimum a toolchain has to satisfy. The toolchain go1.27.1 line is the toolchain the module is built and tested with. A host with Go 1.26 downloads go1.27.1 on the first Go command it runs in the repository. In the tutorial Set up your local Tally that command is the Go probe of make check-tools, and the tutorial says what the download prints there. The Dockerfile builds with golang:1.27.1-alpine, the same version.

Three places name that version and move together: the toolchain line of go.mod, the base image of the Dockerfile, and the version setup-go reads in .github/workflows/ci.yaml. The workflow sets go-version-file: go.mod, so the third one follows the first on its own.

Generated code ​

make generate runs two code generators, then the doc tests that refresh the generated blocks of the site.

GeneratorVersionInputOutput
oapi-codegenOAPI_CODEGEN_VERSION (v2.8.0)api/reporting/openapi.yaml, configured by api/reporting/oapi-codegen.yamlinternal/reporting/httpapi/openapi.gen.go: the chi server interface, the models and the embedded spec
sqlcSQLC_VERSION (v1.31.1)migrations/reporting, internal/reporting/store/queries.sqlinternal/reporting/store/sqlcgen
sqlcSQLC_VERSION (v1.31.1)migrations/engine, internal/engine/store/queries.sqlinternal/engine/store/sqlcgen
sqlcSQLC_VERSION (v1.31.1)migrations/reporting, internal/engine/source/queries.sqlinternal/engine/source/sqlcgen, the engine's read view over the reporting chain
sqlcSQLC_VERSION (v1.31.1)migrations/engine, internal/console/store/queries.sqlinternal/console/store/sqlcgen, the demo console's read view over the engine chain
the doc tests under TALLY_UPDATE_DOCS=1the module's own toolchaindocs/reference_test.go, docs/contributing_test.go, the cmd packages' TestReferencePageIsCurrentthe generated blocks of the reference pages and of the handbook

Generated code is committed, so a plain go build needs no generator. Every generator runs from the module cache at its pinned version, and nothing is installed on the host.

What to regenerate after a change ​

You changedRunWhat changesWhat fails until you do
api/reporting/openapi.yamlmake generateopenapi.gen.go, the endpoints and schemas pagesa handler set that no longer matches the generated server interface fails go build; a stale page fails TestReferencePagesAreCurrent
a file under migrations/reporting/, internal/reporting/store/queries.sql, internal/engine/source/queries.sqlmake generatethe reporting and the source sqlcgen packagesa query a store calls that no longer exists fails go build; internal/reporting/store/migrate_test.go runs the chain against a container
a file under migrations/engine/, internal/engine/store/queries.sql, internal/console/store/queries.sqlmake generatethe engine and the console sqlcgen packagesgo build, the same way; internal/engine/store/migrate_test.go
a Config struct, a cobra command tree, a manifest under deploy/, a dashboard, internal/engine/pricing/pricing.schema.json, internal/core/event/event.go, an export writer, a golden file under internal/engine/export/testdata/golden/make generatethe reference page whose subtest names the source, in docs/reference_test.go or in the TestReferencePageIsCurrent of the binary's cmd packageTestReferencePagesAreCurrent, or that TestReferencePageIsCurrent, reading block "..." differs from its source, run make generate
a ## target: comment of the Makefilemake generatethe make-targets block of The dev stackTestContributingPagesAreCurrent

Lint and format ​

make lint and make fmt run golangci-lint v2 at GOLANGCI_LINT_VERSION (v2.13.2) from the module cache, so neither uses a binary on the host. A golangci-lint built with an older Go than the module's toolchain refuses the module, which is why the targets do not call one. The first run compiles the linter with the module's toolchain and takes a while; later runs start from the build cache.

.golangci.yml enables the standard linter set plus forbidigo with two rules, decimal.NewFromFloat and .InexactFloat64. Money and usage quantities never come from and never leave decimal form (section 6 of roadmap/00-conventions.md), so both patterns are rejected where they appear. The one formatter is gofumpt, which make fmt applies.

CI runs the same version through golangci/golangci-lint-action. Its version: in .github/workflows/ci.yaml and the Makefile's pin are kept equal by hand. go vet ./... runs beside it there and is worth running locally.

Container images ​

make images builds one image per binary from the one Dockerfile. Each build passes --build-arg CMD=<binary> and tags the result <binary>:dev. The file has two stages: the first compiles a static binary with CGO_ENABLED=0, -trimpath and -ldflags="-s -w", the second copies that binary onto gcr.io/distroless/static-debian12:nonroot and runs it as nonroot. Dev and prod run the same image.

The Makefile keeps four lists of images. SERVICES is what make up loads into kind, tally-reporting and tally-engine; The dev stack is that cluster. IMAGES is what make images builds. It adds tally-openstack-collector and tally-openstack-simulator, which the dev stack runs in compose beside a broker. RELEASE_IMAGES is what a release pushes to GHCR: the two SERVICES images, tally-openstack-collector, which the openstack-collector kustomize component runs in a cluster, and tally-reporting-admin, which the migrations component runs beside tally-engine. The admin CLI is in no other list, because the dev stack runs it with go run. Releases says how the push works. SIM_IMAGES is what make simulator-up builds, the collector and the simulator alone; The simulator stack says why.

.dockerignore keeps node_modules/ and the site's build and cache directories, docs/.vitepress/dist/ and docs/.vitepress/cache/, out of the build context.

Debian package ​

make deb builds tally-openstack-collector as a .deb into dist/. The package is for a control node without a container runtime, next to the broker of an OpenStack control plane. The image is for a cluster, where the openstack-collector kustomize component runs the same binary. The collector is the one binary that is packaged; everything else ships as an image alone.

The target cross-compiles linux/$(DEB_GOARCH) with the Dockerfile's build flags, so the packaged binary is the image's binary, and then runs nfpm at NFPM_VERSION (v2.47.0) from the module cache. Nothing is installed on the host and Docker is not involved, so the target runs on macOS as well; reading the result there takes ar x and tar tzvf data.tar.gz, because macOS has no dpkg-deb.

nfpm.yaml at the repository root is the package definition, and packaging/ holds what it installs:

PathContent
/usr/bin/tally-openstack-collectorthe static binary
/lib/systemd/system/tally-openstack-collector.servicethe unit, running as the tally system user
/etc/default/tally-openstack-collectorevery variable with its default, a conffile
/etc/tally/amqp-url, /etc/tally/ingest-tokenthe two secrets, 0640 root:tally, conffiles shipped empty
/var/lib/tally/collector/the outbox directory, 0750 tally:tally

postinstall.sh creates the tally user and group and applies the ownership dpkg cannot resolve at unpack time; preremove.sh stops and disables the unit; postremove.sh drops /etc/tally on purge and keeps the outbox, because between the acknowledgement on the bus and the delivery an event lives in that file and nowhere else.

make sbom builds the package and then writes the SPDX SBOM of the binary it installs into dist/, with syft at SYFT_VERSION (v1.51.1) from the module cache. It catalogs the binary rather than the .deb, because the binary is the only thing in the package with dependencies: syft reads its module list out of the Go build info, which -ldflags="-s -w" leaves in place.

packaging/packaging_test.go pins all of it to openstack.EnvNames and to the paths the unit uses, and it reads files rather than building, so it needs neither Docker nor dpkg. The package step of .github/workflows/ci.yaml is what builds, installs, verifies and purges the package on a runner, and Releases below is what publishes it.

Dependencies ​

go.mod and go.sum pin every module the build resolves. Renovate proposes updates as pull requests: Go modules, npm packages, .nvmrc and the GitHub Actions the workflows use. Its configuration, renovate.json, extends config:recommended and adds one rule: the ghcr.io/b42labs images move in one pull request. The prod overlay names four of them at one tag, and a pull request that moved one alone would fail the test that holds the four tags equal.

The site carries a Node toolchain beside that. package.json pins vitepress exactly, at 1.6.4, and its engines field asks for Node 24 or newer. .nvmrc says 24, which is the version setup-node reads. The committed package-lock.json is what npm ci installs from.

Continuous integration ​

.github/workflows/ci.yaml holds two jobs, and both run on every pull request and on every push to main.

The ci job checks the repository out, runs setup-go against go.mod, and then the lint action, go vet ./..., go test ./... and make check-alerting. Docker on the runner is what the integration tests and check-alerting use; kind is never installed there, so the cluster of The dev stack runs on a contributor's machine alone.

The docs job runs setup-node against .nvmrc, npm ci and npm run docs:build. VitePress fails the build on a dead internal link, so that step is the link check of the site.

deploy-docs.yaml publishes main to GitHub Pages. It runs when a push touches docs/, package.json, package-lock.json, .nvmrc or the workflow itself.

Releases ​

.github/workflows/release.yaml runs on a push of a tag matching v* and publishes a GitHub release from it. The tag is both the trigger and the version source, so a release cannot disagree with what is attached to it. packaging/release-version.sh is the one place the mapping is written: v1.2.3 becomes 1.2.3, and the prerelease tag v1.2.3-rc.1 becomes 1.2.3~rc.1, because dpkg reads everything after a hyphen as the package revision and would sort 1.2.3-rc.1 above 1.2.3, while a tilde sorts below every other character. A tag the script refuses fails the run before anything is built, and a version carrying a tilde marks the release as a prerelease.

The run builds with make sbom, writes SHA256SUMS over the package and the SBOM, and attests all three with actions/attest. That action signs with a short-lived Sigstore certificate minted from the workflow's OIDC token and stores the attestation on the repository, so the project holds no key. Several subjects produce one attestation, whose bundle is attached as attestation.sigstore.json; Install the collector from the Debian package is the operator's side of it. The job holds contents: write, id-token: write and attestations: write, and the workflow holds contents: read. Every action the workflow uses runs at a commit SHA rather than at a tag: it reaches the token of its job, and its owner can move a tag.

The images job runs once the release job has succeeded, so a tag whose release failed publishes no image. It holds contents: read and packages: write alone, logs into ghcr.io with the workflow's token, and builds the four RELEASE_IMAGES from the Dockerfile, then pushes each: ghcr.io/b42labs/tally-reporting:<tag>, ghcr.io/b42labs/tally-engine:<tag>, ghcr.io/b42labs/tally-openstack-collector:<tag> and ghcr.io/b42labs/tally-reporting-admin:<tag>. The entrypoint of each image is its binary, so the admin image takes the subcommand as its arguments. It pushes nothing unless the tag still points at the commit it builds: a re-run builds the commit of its first attempt, so once the tag has moved it would publish that commit under it. An image tag already in the registry fails the job rather than being pushed over, and so does a registry that answers the check with anything but not found. The exception is an image whose org.opencontainers.image.revision label is the tagged commit: an earlier attempt of the job pushed it, so a re-run keeps it and pushes only the images still missing. The tag is the raw Git tag, v1.2.3-rc.1 rather than 1.2.3~rc.1, because a Docker tag cannot carry a tilde. The images are not attested; the Debian package is the one signed artifact. The first push of each image creates its package as private, and an organisation admin makes it public in the package settings.

The run repeats none of the ci job's checks. What judges a commit is the ci run on it, so a tag belongs on a commit whose run is green.

No pull request exercises this workflow, which is why packaging/release_test.go reads it: the tag mapping and its refusals, the trigger, the permissions of both jobs, that every action runs at a commit, the version source, that every RELEASE_IMAGES image is pushed after the release, never over a tag already in the registry and never by a run whose tag has moved, and that every file the release attaches is a subject of the attestation.