Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f46e5f5729 | |||
| 69fcc24a4d | |||
| 6a4f902e38 | |||
| 5f9c202d65 |
+32
-25
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
# Defaults, set here only where this repo differs:
|
||||
# CHARTS_REPO=$HOME/git/charts CHARTS_DIR=<image basename>
|
||||
# GITEA_LOGIN=Ryuvia APPVERSION_PREFIX=
|
||||
# PROSE_LANG=en
|
||||
|
||||
# Same as the image basename, so this is only stated to be read rather than derived.
|
||||
CHARTS_DIR=terdut-server
|
||||
@@ -15,3 +16,9 @@ CHARTS_DIR=terdut-server
|
||||
# version and appVersion from the tag when it publishes -- but people read it, and until
|
||||
# 2026-09-01 it said "latest" while the tree headed for a numbered release.
|
||||
APPVERSION_PREFIX=v
|
||||
|
||||
# English. The Swedish in riksdata and rd-web follows from their subject matter, not from a
|
||||
# house style: terdut-server is an on-call tool whose labels, API and data are English, and
|
||||
# nothing about it is coupled to Swedish. Code comments and docs here were always English;
|
||||
# from 2026-09-01 the release prose is too.
|
||||
PROSE_LANG=en
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
## Release
|
||||
|
||||
Say **"Release"** (or "Release X.Y.Z") and the `release` skill runs it: commit, push, tag,
|
||||
wait for the pipeline, then open the wrapper-chart PR against `Ryuvia/charts`. It stops
|
||||
there — merging and the Flux reconcile stay manual, deliberately.
|
||||
|
||||
Preconditions and the plan, without side effects:
|
||||
|
||||
```sh
|
||||
~/.claude/skills/release/scripts/release-preflight # state + suggested version
|
||||
~/.claude/skills/release/scripts/release-preflight vX.Y.Z # validate that release
|
||||
```
|
||||
|
||||
Config is `.release.conf` here plus `make release-vars`. The process itself lives in
|
||||
`~/.claude/skills/release/`; why it is shaped this way is in README.md §Releasing.
|
||||
|
||||
Two things about this repo specifically:
|
||||
|
||||
- **The pipeline has no image scan.** `.gitea/workflows/release.yaml` runs `test`,
|
||||
`binaries`, `image` and `chart`. A green release run is not evidence the image is
|
||||
CVE-clean, and a release note must not imply it is.
|
||||
- **The wrapper chart has two `tag:` lines** — the app image and the python backup sidecar —
|
||||
so `chart-bump` needs `--image "$IMAGE"` to know which one moves.
|
||||
|
||||
## Checks
|
||||
|
||||
`make fmt lint test helm-lint` mirrors `.gitea/workflows/ci.yaml`, so a green gate here means
|
||||
a green pipeline there. The one deliberate difference is `-race`, which CI does not run; see
|
||||
the comment on the `test` target.
|
||||
|
||||
There are deliberately no `build`/`push`/`helm-push` targets — the workflow owns publishing,
|
||||
and it builds multi-arch. Publishing happens by pushing a tag.
|
||||
@@ -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)
|
||||
|
||||
@@ -57,7 +57,7 @@ with the app — chart `x.y.z` is always app `vx.y.z`:
|
||||
|
||||
```bash
|
||||
helm upgrade --install terdut-server oci://git.ryuvia.com/niklas/terdut-server \
|
||||
--version 0.9.0 \
|
||||
--version 0.9.2 \
|
||||
--namespace terdut-server --create-namespace \
|
||||
--set networking.hostname=terdut.example.com
|
||||
```
|
||||
@@ -661,3 +661,52 @@ go test ./... # run all tests
|
||||
go build ./... # compile all packages
|
||||
go run ./cmd/terdut # run locally
|
||||
```
|
||||
|
||||
`make fmt lint test helm-lint` is the gate. It mirrors `.gitea/workflows/ci.yaml` step for
|
||||
step, so a green run here means a green pipeline — with one deliberate exception: `make test`
|
||||
adds `-race`, which CI does not. The sweeper, the notifier goroutine and the dead man's switch
|
||||
sweep all touch the same single database connection, and a race between them would surface as
|
||||
a flaky incident in production rather than as a red build.
|
||||
|
||||
## Releasing
|
||||
|
||||
```
|
||||
push to main → ci.yaml gofmt, go vet, go test
|
||||
push tag vX.Y.Z → release.yaml same gate, then publish:
|
||||
git.ryuvia.com/niklas/terdut-server:vX.Y.Z
|
||||
oci://git.ryuvia.com/niklas/terdut-server X.Y.Z
|
||||
PR to Ryuvia/charts → bump the wrapper chart to X.Y.Z; on merge
|
||||
Flux reconciles and the release rolls out
|
||||
```
|
||||
|
||||
Both artifacts go to the **personal** Gitea namespace rather than `ryuvia`, because Gitea
|
||||
scopes package visibility to the owner with no per-package override — so `ryuvia/*` is private
|
||||
because the org is. Publishing to `niklas` keeps them anonymously pullable, which is why no
|
||||
pull secret is needed in the cluster. Same reasoning, and the same choice, as riksdata and
|
||||
rd-web.
|
||||
|
||||
Saying **"Release"** runs all three rows: the `release` skill commits, pushes, tags, waits for
|
||||
the pipeline, and opens the `Ryuvia/charts` PR, stopping before the merge. See
|
||||
`~/.claude/skills/release/`, or `.release.conf` here for this repo's part of it.
|
||||
|
||||
The chart is published **only** from the tag, by the `chart` job. There used to be a second
|
||||
publisher on every `charts/**` push to main, and the two raced for the same chart version with
|
||||
different answers — chart 0.9.0 went out reading `appVersion: "latest"` that way. One
|
||||
publisher, triggered by the tag (`766f439`). The cost is that a chart-only change has no
|
||||
version of its own and rides the next app tag.
|
||||
|
||||
There is deliberately **no** `make build` / `make push`. The workflow builds
|
||||
`linux/amd64,linux/arm64` through buildx; a local single-platform push would land on top of
|
||||
the multi-arch tag and stay invisible, because the tag would still resolve — just not on
|
||||
arm64. Publishing happens by pushing a tag.
|
||||
|
||||
Two things the release process needs to know about this repo:
|
||||
|
||||
- **The pipeline has no image scan**, unlike riksdata and rd-web. A green release run is not
|
||||
evidence the image is CVE-clean.
|
||||
- **The wrapper chart's `values.yaml` has two `tag:` lines** — the app image and the python
|
||||
backup sidecar — so `chart-bump` is given `--image` to say which one moves.
|
||||
|
||||
The wrapper chart must have **its own `version:` bumped in the same commit**. Flux reconciles
|
||||
with `reconcileStrategy: ChartVersion`, so a chart whose version did not change produces no
|
||||
new artifact and the change is never deployed — with no error anywhere.
|
||||
|
||||
@@ -6,13 +6,14 @@ type: application
|
||||
# released values. .gitea/workflows/release.yaml rewrites both from the git tag when it
|
||||
# publishes, so the chart version always equals the app version.
|
||||
#
|
||||
# They are kept in step with the tag anyway. Being read is the only thing these two
|
||||
# lines do -- nothing that publishes looks at them -- and a tree heading for v0.9.2 that
|
||||
# says 0.9.0 tells its reader something false. That is what they said until 2026-09-01,
|
||||
# They are kept in step with the tag anyway. Being read is the only thing these two lines
|
||||
# do -- `helm package --version --app-version` sets the published values from the tag and
|
||||
# never consults these -- and a tree heading for a numbered release that states an older
|
||||
# number tells its reader something false. They said 0.9.0 and "latest" until 2026-09-01,
|
||||
# through two releases.
|
||||
#
|
||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||
# metadata and drives nothing.
|
||||
version: 0.9.2
|
||||
appVersion: "v0.9.2"
|
||||
version: 0.9.3
|
||||
appVersion: "v0.9.3"
|
||||
|
||||
Reference in New Issue
Block a user