REGISTRY := git.ryuvia.com # The personal namespace, not ryuvia — deliberately, and for one reason: Gitea # scopes package visibility to the owner with no per-package override, so # ryuvia/* is private because the org is. Publishing here keeps the image and # chart anonymously pullable, so no pull secret is needed in the cluster and # Flux needs no registry credentials. Same choice riksdata and rd-web made. OWNER := niklas IMAGE := $(REGISTRY)/$(OWNER)/terdut-server HELM_CHART := charts/terdut-server HELM_REPO := oci://$(REGISTRY)/$(OWNER) # go.mod pins an exact patch release so nobody builds the shipped binary with a # toolchain carrying known stdlib CVEs. Fedora's Go package overrides the # upstream GOTOOLCHAIN default to `local`, which turns that pin into a hard # failure on a dev box one patch behind, so restore the upstream default here. export GOTOOLCHAIN ?= auto .PHONY: help help: ## Show this help @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}' ## --- checks --- # # These three mirror .gitea/workflows/ci.yaml step for step, so a green `make fmt # lint test` here means the same thing CI means. The one deliberate difference is # -race below. .PHONY: test test: ## Run the test suite go test -race ./... # CI runs a bare `go test ./...`. This is stricter on purpose: the sweeper, the # notifier goroutine and the deadman sweep all touch the same single-connection # database, and a race there would surface as a flaky production incident rather # than a failed build. It passes today; if it ever costs more than it catches, # the honest fix is to teach CI -race too, not to quietly drop it here. .PHONY: lint lint: ## go vet go vet ./... # Copied from ci.yaml rather than simplified, because both of gofmt's failure # modes need handling and they are not alike. A file that is merely misformatted # is listed on stdout with exit 0 — so the failure has to be raised by hand. A # file that does not parse is the opposite: nothing on stdout and exit 2, which a # naive `[ -n "$$out" ]` reads as success. See 9046f6e. .PHONY: fmt fmt: ## Report unformatted files @if ! unformatted=$$(gofmt -l .); then \ echo "gofmt could not parse the tree:"; gofmt -l .; exit 1; \ fi; \ if [ -n "$$unformatted" ]; then \ echo "gofmt needed:"; echo "$$unformatted"; gofmt -d .; exit 1; \ fi .PHONY: helm-lint helm-lint: ## Lint and render the chart helm lint $(HELM_CHART) --set image.tag=v0.0.0 helm template terdut-server $(HELM_CHART) --namespace terdut-server \ --set image.tag=v0.0.0 >/dev/null @# networking.listener defaults to "", which attaches the route to every @# matching listener including plaintext HTTP. Production sets it, so the @# default render proves nothing about the path that actually ships. helm template terdut-server $(HELM_CHART) --namespace terdut-server \ --set image.tag=v0.0.0 --set networking.listener=https-terdut >/dev/null ## --- release --- # The release process (~/.claude/skills/release) reads these rather than restating them. # One definition, so the version that gets tagged, the image that gets pushed and the chart # the wrapper pins cannot drift apart in a second copy. .PHONY: release-vars release-vars: ## Print the variables the release process reads @printf 'IMAGE=%s\nHELM_CHART=%s\nHELM_REPO=%s\n' '$(IMAGE)' '$(HELM_CHART)' '$(HELM_REPO)' # There is deliberately no build/push/helm-package/helm-push/release here, unlike # riksdata and rd-web. .gitea/workflows/release.yaml owns publishing for this repo, # and it does two things a local make cannot: it builds linux/amd64 and linux/arm64 # through buildx, and it stamps the chart's version and appVersion from the tag. A # `docker build && docker push` target would push a single-architecture image over # the multi-arch tag, which is both easy to do by accident and invisible afterwards # — the tag would still resolve, just not on arm64. Publishing happens by pushing a # tag; nothing else. ## --- publishing --- # # These exist so .gitea/workflows/release.yaml can call `make release` instead of # restating the build in YAML, the way riksdata and rd-web already do. One definition # of how this is built and published, runnable locally, reviewable in a diff. # # VERSION is the git tag, passed in by the workflow. The guard below is why a stray # local `make release` cannot publish: dev is not a version anyone releases. VERSION ?= dev # Helm requires strict SemVer — strip a leading 'v' if present. CHART_VERSION := $(shell echo "$(VERSION)" | sed 's/^v//') PLATFORMS ?= linux/amd64,linux/arm64 BUILDX_BUILDER ?= terdut TRIVY_VERSION := 0.73.0 GOVULNCHECK_VERSION := v1.1.4 GITLEAKS_VERSION := v8.30.0 # --pull, not --no-cache: refresh the base image without discarding the layer cache. DOCKER_BUILD_FLAGS ?= --pull # An isolated repo list. The machine-wide one is not this build's business, and one # unreachable entry in it aborts otherwise-fine helm commands — there is a dead # TrueCharts repo on this host that does exactly that. HELM_REPOSITORY_CACHE is # deliberately NOT overridden alongside it: helm writes a refreshed index to the default # cache and then looks for it in the overridden one. HELM_ISOLATED = HELM_REPOSITORY_CONFIG=$(CURDIR)/.helm-repos.yaml .PHONY: require-version require-version: @test "$(VERSION)" != "dev" || \ (echo "VERSION=dev names no release — pass VERSION=vX.Y.Z (the workflow passes the tag)" && exit 1) .PHONY: build build: ## Build the image for this host only, without pushing (local check / CI smoke) docker build $(DOCKER_BUILD_FLAGS) \ --build-arg VERSION=$(VERSION) \ -t $(IMAGE):$(VERSION) . # Multi-arch, so unlike riksdata and rd-web this cannot be a separate build then push: # buildx cannot load a multi-platform result into the local image store, it can only # push it. `build` above stays single-platform and local-only for that reason. # # No QEMU: the Dockerfile's builder stage runs on $$BUILDPLATFORM and cross-compiles from # TARGETARCH, so both platforms build natively. The default "docker" driver cannot build # more than one platform at a time; the docker-container driver can. .PHONY: push push: require-version ## Build and publish the multi-arch image docker buildx create --name $(BUILDX_BUILDER) --use 2>/dev/null || docker buildx use $(BUILDX_BUILDER) docker buildx build \ --platform $(PLATFORMS) \ --build-arg "VERSION=$(VERSION)" \ --tag "$(IMAGE):latest" \ --tag "$(IMAGE):$(VERSION)" \ --push . # --version and --app-version come from the tag, so Chart.yaml's own fields decide nothing # about what is published. They used to be rewritten in place with sed before packaging; # the flags do the same job without mutating the tree mid-build. .PHONY: helm-package helm-package: require-version ## Package the chart, versioned from the tag $(HELM_ISOLATED) helm package $(HELM_CHART) \ --version $(CHART_VERSION) \ --app-version $(VERSION) \ --destination dist .PHONY: helm-push helm-push: require-version ## Push the packaged chart to the OCI registry $(HELM_ISOLATED) helm push dist/terdut-server-$(CHART_VERSION).tgz $(HELM_REPO) .PHONY: binaries binaries: require-version ## Cross-compile the release binaries into dist/ @mkdir -p dist @set -eu; for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do \ GOOS="$${target%/*}"; GOARCH="$${target#*/}"; \ out="dist/terdut-$(VERSION)-$${GOOS}-$${GOARCH}"; \ echo "building $$out"; \ GOOS="$$GOOS" GOARCH="$$GOARCH" go build \ -ldflags "-w -s -X main.version=$(VERSION)" \ -o "$$out" ./cmd/terdut; \ done .PHONY: release release: push helm-package helm-push ## Publish image + chart (the workflow's one call) ## --- security --- # Symbol-level, not dependency-level: govulncheck reports a vulnerability only when the # code can actually reach it. As of 2026-09-02 this repo imports three chi advisories and # reports none of them, because all three are middleware.RealIP and router.go uses Logger # and Recoverer. That is the useful property rather than a loophole -- adding # middleware.RealIP would turn this red, which is exactly when someone should look. .PHONY: security-go security-go: ## Scan Go deps for known CVEs (govulncheck) go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./... # --no-git scans the working tree rather than the history, so this catches a secret on the # way in. It is not a history audit and finding nothing here says nothing about what is # already committed. --redact because the finding is printed into a CI log. # # Note when testing it that gitleaks allowlists well-known example credentials -- the AWS # key from their own documentation does not trip it. A private key block does. .PHONY: security-secrets security-secrets: ## Scan the working tree for committed secrets (gitleaks) go run github.com/zricethezav/gitleaks/v8@$(GITLEAKS_VERSION) detect --no-git \ --source . --redact --no-banner --exit-code 1 # Scans the pushed image, not a local one: trivy cannot read a locally built image on the # runner -- Talos has no docker socket, and the dind sidecar shares no filesystem with the # job -- so it pulls from the registry. Same reason riksdata and rd-web scan after pushing. # # It cannot gate a deploy, because this pipeline does not deploy. A red scan means: do not # bump the wrapper chart in Ryuvia/charts to this version. # # The image is FROM scratch, so there are no OS packages to scan and trivy sees exactly one # target -- the Go binary and its module graph. That also makes scanning a single platform # sufficient here: linux/amd64 and linux/arm64 are the same modules built for a different # GOARCH, so a CVE in one is a CVE in both. On an image with a base layer that would not # hold and both platforms would need scanning. # # This is the last of the three scans and the only one that needs a published artifact; # security-go and security-secrets above run on every push. .PHONY: security-image security-image: require-version ## Scan the pushed image for CVEs (needs VERSION) @# The named volume persists trivy's vulnerability DB between runs; without it every @# scan re-downloads the whole database. docker run --rm -e TRIVY_USERNAME -e TRIVY_PASSWORD \ -v trivy-cache:/root/.cache/trivy \ docker.io/aquasec/trivy:$(TRIVY_VERSION) image --severity HIGH,CRITICAL \ --ignore-unfixed --exit-code 1 $(IMAGE):$(VERSION)