Drive the pipeline through make, the way riksdata and rd-web do
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
This commit is contained in:
+32
-25
@@ -59,32 +59,39 @@ jobs:
|
|||||||
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# This exists because `go vet` does not look at import order: the move to
|
# The gate is the Makefile's rather than a second copy of it here, the way riksdata
|
||||||
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
|
# and rd-web already do it. `make fmt lint test` is exactly what a developer runs, so
|
||||||
# before github.com/..., and both repos sat unformatted through a green CI run and
|
# a green pipeline and a green working copy mean the same thing by construction
|
||||||
# a release before anyone noticed.
|
# 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
|
# The reasoning that used to live here moved with the targets: why gofmt is checked
|
||||||
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
|
# at all (import order survives `go vet`, and both repos sat unformatted through a
|
||||||
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
|
# green run and a release -- 9046f6e), why both of gofmt's failure modes need
|
||||||
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
|
# handling, and why `test` adds -race when this job does not have to.
|
||||||
# draft of this step had exactly that hole.
|
- name: Format, vet and test
|
||||||
- name: Format
|
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: |
|
run: |
|
||||||
if ! unformatted=$(gofmt -l .); then
|
if [ -n "$HEAD_SHA" ]; then
|
||||||
echo "::error::gofmt could not parse the tree"
|
git clone "$REPO_URL" .
|
||||||
gofmt -l . # re-run unredirected so the parse errors reach the log
|
git checkout -q "$HEAD_SHA"
|
||||||
exit 1
|
else
|
||||||
fi
|
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
if [ -n "$unformatted" ]; then
|
|
||||||
echo "::error::not gofmt'd:"
|
|
||||||
echo "$unformatted"
|
|
||||||
gofmt -d .
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Vet
|
- name: Lint and render the chart
|
||||||
run: go vet ./...
|
run: make helm-lint
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: go test ./...
|
|
||||||
|
|||||||
@@ -41,35 +41,10 @@ jobs:
|
|||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
||||||
|
|
||||||
# This exists because `go vet` does not look at import order: the move to
|
# Same gate as ci.yaml, and the same one a developer runs. See the Makefile for why
|
||||||
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
|
# each check is there; restating it here is how the two drift apart.
|
||||||
# before github.com/..., and both repos sat unformatted through a green CI run and
|
- name: Format, vet and test
|
||||||
# a release before anyone noticed.
|
run: make fmt lint test
|
||||||
#
|
|
||||||
# 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 ./...
|
|
||||||
|
|
||||||
binaries:
|
binaries:
|
||||||
needs: test
|
needs: test
|
||||||
@@ -86,21 +61,13 @@ jobs:
|
|||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
|
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
|
- name: Build every target
|
||||||
env:
|
env:
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
run: |
|
run: make binaries VERSION="$REF_NAME"
|
||||||
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
|
|
||||||
|
|
||||||
# Creating the release is made idempotent rather than assumed-new: a re-run of a
|
# 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
|
# failed release must not die on the release that already exists. Assets are
|
||||||
@@ -158,23 +125,12 @@ jobs:
|
|||||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: echo "$TOKEN" | docker login "$REGISTRY" -u niklas --password-stdin
|
run: echo "$TOKEN" | docker login "$REGISTRY" -u niklas --password-stdin
|
||||||
|
|
||||||
# The default "docker" driver cannot build more than one platform at a time; the
|
# buildx setup, the platform list and why there is no QEMU all live on the `push`
|
||||||
# docker-container driver can. Reused across runs if it survived the last one.
|
# target now, so the same command publishes from a laptop and from here.
|
||||||
- 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.
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
env:
|
env:
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
run: |
|
run: make push VERSION="$REF_NAME"
|
||||||
docker buildx build \
|
|
||||||
--platform linux/amd64,linux/arm64 \
|
|
||||||
--build-arg "VERSION=${REF_NAME}" \
|
|
||||||
--tag "${IMAGE}:latest" \
|
|
||||||
--tag "${IMAGE}:${REF_NAME}" \
|
|
||||||
--push .
|
|
||||||
|
|
||||||
# Also host mode: helm is baked into the runner image, and a `container:` job could not
|
# 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.
|
# install it -- get.helm.sh is unreachable from the dind bridge.
|
||||||
@@ -200,10 +156,10 @@ jobs:
|
|||||||
# tagged with. One publisher, triggered by the tag.
|
# 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 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
|
# has no version of its own to be released under anyway. Chart fixes ride the next
|
||||||
# tag.
|
# tag.
|
||||||
- name: Stamp the chart version from the tag
|
- name: Refuse a non-version tag
|
||||||
env:
|
env:
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
@@ -212,11 +168,17 @@ jobs:
|
|||||||
echo "::error::refusing to publish a chart for non-version tag ${REF_NAME}"
|
echo "::error::refusing to publish a chart for non-version tag ${REF_NAME}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
- name: Package and push
|
||||||
env:
|
env:
|
||||||
REF_NAME: ${{ github.ref_name }}
|
REF_NAME: ${{ github.ref_name }}
|
||||||
@@ -224,12 +186,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
echo "$TOKEN" | helm registry login "$REGISTRY" -u niklas --password-stdin
|
echo "$TOKEN" | helm registry login "$REGISTRY" -u niklas --password-stdin
|
||||||
# Isolated repo config: the machine-wide helm repo list is not this job's
|
make helm-package helm-push VERSION="$REF_NAME"
|
||||||
# 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"
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
# build output
|
# build output
|
||||||
/terdut
|
/terdut
|
||||||
/terdut-server
|
/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
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -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 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
|
# — the tag would still resolve, just not on arm64. Publishing happens by pushing a
|
||||||
# tag; nothing else.
|
# 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user