9046f6e026
CI / test (push) Successful in 4s
go vet says nothing about import order, so when the move to git.ryuvia.com rewrote every import path without re-sorting them -- the new path sorts before github.com/..., where the old one sorted after -- both repos went through a green CI run and a release unformatted. Added to the release workflow as well as CI, so the two keep running the same checks; ci.yaml's header claims exactly that, and a check in one but not the other would quietly make it false. The step handles gofmt's two failure modes separately because they do not look alike: a misformatted file is listed on stdout with exit 0, so the failure has to be raised by hand, while a file that does not parse prints nothing to stdout and exits 2 -- which a plain emptiness test reads as success. Verified against all three cases (clean, misformatted, unparseable) before committing.
236 lines
9.7 KiB
YAML
236 lines
9.7 KiB
YAML
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"
|