From 69fcc24a4d9aed69a32487dd2ccda60101a1ccbf Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Tue, 1 Sep 2026 22:36:54 +0200 Subject: [PATCH] Drive the pipeline through make, the way riksdata and rd-web do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both workflows restated the build in YAML: gofmt, go vet and go test inline in two places, buildx inline in a third, and the chart's version sed'd into Chart.yaml before packaging. The Makefile added in 1081260 then described the same checks a second time for local use, which made "green locally means green in CI" a promise about keeping two files in step rather than a property of the setup. riksdata and rd-web never had that problem — their workflows call make and have done all along. This repo was the odd one out, and only because it had no Makefile until today. Now ci.yaml runs `make fmt lint test`, release.yaml runs the same plus `make binaries`, `make push`, `make helm-package` and `make helm-push`, and the reasoning behind each check lives on the target rather than in whichever YAML file was edited last. Three things change rather than just move: The chart is linted before it is published. release.yaml packaged and pushed without ever rendering the templates, so a chart that did not compile would have reached the registry and been found by Flux. ci.yaml gained a chart job for the same reason. helm package --version --app-version replaces the sed. The published metadata is identical, but the tree is no longer mutated mid-build, and it is what the rest of the release process already assumed happened. `make push` refuses VERSION=dev. Publishing is one command now, so it is also one command to run by accident; dev is not a version anyone releases. Deliberately not moved: uploading the release assets. Compiling them is `make binaries` and runs anywhere, but the upload needs a token and the Gitea release API, which is the workflow's business and not something worth a target. `push` builds and pushes in one step, unlike riksdata's separate build and push, because buildx cannot load a multi-platform image into the local store — it can only push it. `build` stays single-platform and local-only. Claude-Session: https://claude.ai/code/session_01S7R4gWTz5wh5xCY4nCSJjN --- .gitea/workflows/ci.yaml | 57 +++++++++++---------- .gitea/workflows/release.yaml | 94 +++++++++-------------------------- .gitignore | 5 ++ Makefile | 84 +++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 95 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 77001d9..bda34ec 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -59,32 +59,39 @@ jobs: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . fi - # This exists because `go vet` does not look at import order: the move to - # git.ryuvia.com rewrote every import path without re-sorting, the new path sorts - # before github.com/..., and both repos sat unformatted through a green CI run and - # a release before anyone noticed. + # The gate is the Makefile's rather than a second copy of it here, the way riksdata + # and rd-web already do it. `make fmt lint test` is exactly what a developer runs, so + # a green pipeline and a green working copy mean the same thing by construction + # instead of by remembering to update two files together. # - # 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 "$unformatted" ]` reads as success. The first - # draft of this step had exactly that hole. - - name: Format + # The reasoning that used to live here moved with the targets: why gofmt is checked + # at all (import order survives `go vet`, and both repos sat unformatted through a + # green run and a release -- 9046f6e), why both of gofmt's failure modes need + # handling, and why `test` adds -race when this job does not have to. + - name: Format, vet and test + run: make fmt lint test + + # Host mode, no `container:`: helm is baked into the runner image, and a container job + # could not install it -- get.helm.sh is unreachable from the dind bridge. Same reason + # release.yaml's chart job runs on the host. + # + # The chart had no lint step in any workflow until 2026-09-01: release.yaml packaged and + # pushed it without rendering it first, so a template that did not compile would have + # been found by Flux rather than here. + chart: + runs-on: ubuntu-latest + steps: + - name: Checkout + env: + REF_NAME: ${{ github.ref_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - if ! unformatted=$(gofmt -l .); then - echo "::error::gofmt could not parse the tree" - gofmt -l . # re-run unredirected so the parse errors reach the log - exit 1 - fi - if [ -n "$unformatted" ]; then - echo "::error::not gofmt'd:" - echo "$unformatted" - gofmt -d . - exit 1 + if [ -n "$HEAD_SHA" ]; then + git clone "$REPO_URL" . + git checkout -q "$HEAD_SHA" + else + git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . fi - - name: Vet - run: go vet ./... - - - name: Test - run: go test ./... + - name: Lint and render the chart + run: make helm-lint diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 14f0c87..ee2bee5 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -41,35 +41,10 @@ jobs: REF_NAME: ${{ github.ref_name }} run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . - # This exists because `go vet` does not look at import order: the move to - # git.ryuvia.com rewrote every import path without re-sorting, the new path sorts - # before github.com/..., and both repos sat unformatted through a green CI run and - # a release before anyone noticed. - # - # 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 "$unformatted" ]` reads as success. The first - # draft of this step had exactly that hole. - - name: Format - run: | - if ! unformatted=$(gofmt -l .); then - echo "::error::gofmt could not parse the tree" - gofmt -l . # re-run unredirected so the parse errors reach the log - exit 1 - fi - if [ -n "$unformatted" ]; then - echo "::error::not gofmt'd:" - echo "$unformatted" - gofmt -d . - exit 1 - fi - - - name: Vet - run: go vet ./... - - - name: Test - run: go test ./... + # Same gate as ci.yaml, and the same one a developer runs. See the Makefile for why + # each check is there; restating it here is how the two drift apart. + - name: Format, vet and test + run: make fmt lint test binaries: needs: test @@ -86,21 +61,13 @@ jobs: REF_NAME: ${{ github.ref_name }} run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . + # Compiling is the Makefile's; uploading is not. `make binaries` is runnable on a + # laptop, while the step below needs a token and the Gitea release API, which is + # this workflow's business and nothing a developer wants a target for. - name: Build every target env: REF_NAME: ${{ github.ref_name }} - run: | - set -eu - mkdir -p dist - for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do - GOOS="${target%/*}" - GOARCH="${target#*/}" - out="dist/terdut-${REF_NAME}-${GOOS}-${GOARCH}" - echo "building $out" - GOOS="$GOOS" GOARCH="$GOARCH" go build \ - -ldflags "-w -s -X main.version=${REF_NAME}" \ - -o "$out" ./cmd/terdut - done + run: make binaries VERSION="$REF_NAME" # Creating the release is made idempotent rather than assumed-new: a re-run of a # failed release must not die on the release that already exists. Assets are @@ -158,23 +125,12 @@ jobs: TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: echo "$TOKEN" | docker login "$REGISTRY" -u niklas --password-stdin - # The default "docker" driver cannot build more than one platform at a time; the - # docker-container driver can. Reused across runs if it survived the last one. - - name: Prepare buildx - run: docker buildx create --name terdut --use 2>/dev/null || docker buildx use terdut - - # No QEMU: the Dockerfile's builder stage runs on $BUILDPLATFORM and cross-compiles - # from TARGETARCH, so both platforms build natively. See the comment in Dockerfile. + # buildx setup, the platform list and why there is no QEMU all live on the `push` + # target now, so the same command publishes from a laptop and from here. - name: Build and push env: REF_NAME: ${{ github.ref_name }} - run: | - docker buildx build \ - --platform linux/amd64,linux/arm64 \ - --build-arg "VERSION=${REF_NAME}" \ - --tag "${IMAGE}:latest" \ - --tag "${IMAGE}:${REF_NAME}" \ - --push . + run: make push VERSION="$REF_NAME" # Also host mode: helm is baked into the runner image, and a `container:` job could not # install it -- get.helm.sh is unreachable from the dind bridge. @@ -200,10 +156,10 @@ jobs: # tagged with. One publisher, triggered by the tag. # # The cost is that the chart only ships with an app release. That is no real loss -- - # the sed below ties the chart version to the app version, so a chart-only change + # `make helm-package` derives the chart version from the tag, so a chart-only change # has no version of its own to be released under anyway. Chart fixes ride the next # tag. - - name: Stamp the chart version from the tag + - name: Refuse a non-version tag env: REF_NAME: ${{ github.ref_name }} run: | @@ -212,11 +168,17 @@ jobs: echo "::error::refusing to publish a chart for non-version tag ${REF_NAME}" exit 1 fi - CHART_VERSION="${REF_NAME#v}" - sed -i "s/^version:.*/version: ${CHART_VERSION}/" charts/terdut-server/Chart.yaml - sed -i "s/^appVersion:.*/appVersion: \"${REF_NAME}\"/" charts/terdut-server/Chart.yaml - cat charts/terdut-server/Chart.yaml + # Render before publishing. Until 2026-09-01 this job packaged and pushed without + # linting, so a template that did not compile reached the registry and was found by + # Flux instead. + - name: Lint and render the chart + run: make helm-lint + + # The version and appVersion are no longer sed'd into Chart.yaml before packaging: + # `helm package --version --app-version` sets both from the tag without mutating the + # tree mid-build, which is what the rest of the release process already assumed + # happened. The isolated helm repo list moved onto the targets with them. - name: Package and push env: REF_NAME: ${{ github.ref_name }} @@ -224,12 +186,4 @@ jobs: run: | set -eu echo "$TOKEN" | helm registry login "$REGISTRY" -u niklas --password-stdin - # Isolated repo config: the machine-wide helm repo list is not this job's - # business, and one unreachable entry in it aborts otherwise-fine commands. - # 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. - export HELM_REPOSITORY_CONFIG="$PWD/.helm-repos.yaml" - : > "$HELM_REPOSITORY_CONFIG" - helm package charts/terdut-server -d dist - helm push "dist/terdut-server-${REF_NAME#v}.tgz" "oci://${REGISTRY}/niklas" + make helm-package helm-push VERSION="$REF_NAME" diff --git a/.gitignore b/.gitignore index d53fe53..aa08c62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ # build output /terdut /terdut-server +# `make binaries` and `make helm-package` write here +/dist/ +# isolated helm repo list written by the publishing targets, so the machine-wide +# one (which has an unreachable entry) cannot abort a release +/.helm-repos.yaml # SQLite database files *.db diff --git a/Makefile b/Makefile index 95a7328..19b50da 100644 --- a/Makefile +++ b/Makefile @@ -82,3 +82,87 @@ release-vars: ## Print the variables the release process reads # 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 + +# --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 "refusing to publish VERSION=dev — 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)