name: Release # Checkout, interpolation and caching conventions match ci.yaml -- see the header there # for why there are no JS actions and why every `${{ }}` goes through `env:`. # # There is no upload-artifact/download-artifact equivalent here (both are JS actions, and # this Gitea has no artifact store wired up), so the job that builds the binaries is also # the job that publishes them. Nothing is handed between jobs at all. on: push: tags: - 'v*' workflow_dispatch: # A tag is not normally re-pushed, so this mostly matters when one is force-moved during # a botched release -- the superseded run stops holding runner slots. concurrency: group: release-${{ github.ref }} cancel-in-progress: true env: REPO_URL: https://git.ryuvia.com/niklas/terdut-server.git API: https://git.ryuvia.com/api/v1/repos/niklas/terdut-server REGISTRY: git.ryuvia.com IMAGE: git.ryuvia.com/niklas/terdut-server jobs: # Gates every publishing job below. A tag that fails here publishes nothing: the # binaries, the image and the chart are all downstream of it. test: runs-on: ubuntu-latest container: image: golang:1.26.6-bookworm volumes: - go-mod-cache:/go/pkg/mod - go-build-cache:/root/.cache/go-build - gobin-cache:/go/bin steps: - name: Checkout env: 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 ./... binaries: needs: test runs-on: ubuntu-latest container: image: golang:1.26.6-bookworm volumes: - go-mod-cache:/go/pkg/mod - go-build-cache:/root/.cache/go-build - gobin-cache:/go/bin steps: - name: Checkout env: REF_NAME: ${{ github.ref_name }} run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . - 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 # 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 # replaced the same way, so a re-run repairs a partial upload. - name: Publish the release env: REF_NAME: ${{ github.ref_name }} TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu auth="Authorization: token $TOKEN" body=$(curl -sf -H "$auth" "$API/releases/tags/$REF_NAME" || true) if [ -z "$body" ]; then body=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \ -d "{\"tag_name\":\"$REF_NAME\",\"name\":\"$REF_NAME\"}" \ "$API/releases") fi # The release object serialises `id` first, so the first match is the release's # own id and not one of the nested author/asset ids. release_id=$(printf '%s' "$body" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) [ -n "$release_id" ] || { echo "::error::could not determine release id"; exit 1; } echo "release id $release_id" for f in dist/*; do name=$(basename "$f") # Drop an existing asset of the same name first: Gitea happily stores two # attachments with one name, and the updater matches by name. old=$(curl -sf -H "$auth" "$API/releases/$release_id/assets" \ | tr '}' '\n' | grep "\"name\":\"$name\"" \ | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true) if [ -n "$old" ]; then curl -sf -X DELETE -H "$auth" "$API/releases/$release_id/assets/$old" || true fi echo "uploading $name" curl -sf -X POST -H "$auth" -F "attachment=@$f" \ "$API/releases/$release_id/assets?name=$name" > /dev/null done # Host mode on purpose (no `container:`): this is the only context with a Docker CLI # pointed at the dind daemon. A `container:` job would sit on the dind bridge with no # docker socket at all. image: needs: test runs-on: ubuntu-latest steps: - name: Checkout env: REF_NAME: ${{ github.ref_name }} run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . - name: Log in to the registry env: 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. - 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 . # 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. chart: needs: test runs-on: ubuntu-latest steps: - name: Checkout env: REF_NAME: ${{ github.ref_name }} run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . # This job is the only thing that publishes the chart, which is what keeps the # published metadata honest. 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: # this one stamps version and appVersion from the tag, that one took Chart.yaml # verbatim, where appVersion is the hardcoded "latest". Whichever landed first won, # so the metadata of a release depended on which runner was quicker -- chart 0.9.0 # went out on 2026-08-08 reading appVersion "latest" that way. # # It could not be fixed by making both agree: the tag is pushed after the branch, so # a workflow triggered by the main push cannot know the version it is about to be # 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 # 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 env: REF_NAME: ${{ github.ref_name }} run: | set -eu if ! echo "$REF_NAME" | grep -qE '^v[0-9]'; then 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 - name: Package and push env: REF_NAME: ${{ github.ref_name }} TOKEN: ${{ secrets.REGISTRY_TOKEN }} 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"