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" . # 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 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" . # 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: 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 # 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 # 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: 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. 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 -- # `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: Refuse a non-version 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 # 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 }} TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -eu echo "$TOKEN" | helm registry login "$REGISTRY" -u niklas --password-stdin make helm-package helm-push VERSION="$REF_NAME" # Host mode, like image and chart: this needs a docker daemon to run trivy in, and a # `container:` job would sit on the dind bridge with none. # # It scans the pushed image rather than a locally built one, because trivy cannot read a # local image on this runner -- Talos has no docker socket and the dind sidecar shares no # filesystem with the job -- so it pulls from the registry. That is also why this runs # after `image` rather than gating it: a red scan does not unpublish anything. # # What a red scan means is therefore not "the release failed" but "do not bump the wrapper # chart in Ryuvia/charts to this version". The image and chart are already published by # the time this runs, and deliberately so -- this pipeline does not deploy. # # riksdata and rd-web have had this since they were set up; terdut-server went without any # image scanning until 2026-09-02, so every release before v0.9.4 was published with no # CVE check at all. scan-image: needs: image runs-on: ubuntu-latest steps: - name: Checkout env: REF_NAME: ${{ github.ref_name }} run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" . # Credentials are passed even though these packages are anonymously pullable -- that # is a property of the personal namespace this publishes to, not something a release # should depend on staying true. - name: Scan the pushed image (trivy) env: TRIVY_USERNAME: niklas TRIVY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} REF_NAME: ${{ github.ref_name }} run: make security-image VERSION="$REF_NAME"