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. # # The asset names matter beyond being tidy: internal/updater looks for exactly # terdut-tui--- in the latest release and reports every available name # when it cannot find one. Renaming the pattern here breaks self-update for every # installed binary. on: push: tags: - 'v*' workflow_dispatch: concurrency: group: release-${{ github.ref }} cancel-in-progress: true env: REPO_URL: https://git.ryuvia.com/niklas/terdut-tui.git API: https://git.ryuvia.com/api/v1/repos/niklas/terdut-tui jobs: # Gates the build, so a tag that fails here publishes no binaries. 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-tui-${REF_NAME}-${GOOS}-${GOARCH}" echo "building $out" GOOS="$GOOS" GOARCH="$GOARCH" go build \ -ldflags "-X main.version=${REF_NAME}" \ -o "$out" . 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" # The release notes are the tag's own message, minus its subject line: the # tag body is the changelog for this project, and without this the release # page stays empty. Only an annotated tag has one, and only a release with # no notes is filled, so a re-run repairs a release created empty without # overwriting notes somebody has since edited by hand. # # There is no jq in this image, so the JSON string is escaped by hand: # backslashes first (or the ones added next would double), then quotes and # tabs, then each line end becomes a literal \n. if [ "$(git cat-file -t "$REF_NAME")" = tag ] \ && printf '%s' "$body" | grep -q '"body":""'; then notes=$(git tag -l --format='%(contents)' "$REF_NAME" | sed '1,2d') if [ -n "$notes" ]; then notes_json=$(printf '%s\n' "$notes" \ | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/\\t/g' -e 's/\r$//' \ | awk 'BEGIN { ORS = "\\n" } { print }') echo "setting release notes from the tag message" curl -sf -X PATCH -H "$auth" -H 'Content-Type: application/json' \ -d "{\"body\":\"$notes_json\"}" "$API/releases/$release_id" > /dev/null fi fi 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