From 2c576fa1e893864d0f70e1c9a288b279186aa45b Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 17 Aug 2026 18:39:06 -0400 Subject: [PATCH] ci(release): attach the Linux, Arch and Android builds to the release A release page with nothing to download is one nobody can use. The Arch package and the APK are already built and merely go unattached; the plain Linux binary is new, and is what answers 'get the latest version' without a package manager. scripts/release-asset.sh waits for the release to exist first. semantic-release pushes the tag in prepare and creates the release in publish, so the tag push that starts these workflows happens before there is an id to upload to -- and a capacity-1 runner serialises that into working by accident, which is the worst kind of bug. macOS is absent because it cannot be built here: GOOS=darwin CGO_ENABLED=0 fails at wails/v3/pkg/mac, the darwin backend being Objective-C behind cgo. Homebrew builds from source on the user's Mac and stays the macOS channel. Windows cross-compiles cleanly and is still withheld: no build of it has ever been run. All three skip v0.0.0, which is semantic-release's version floor rather than a shipment. --- .gitea/workflows/android-apk.yml | 47 +++++++ .gitea/workflows/desktop-assets.yml | 176 ++++++++++++++++++++++++++ .gitea/workflows/homebrew-formula.yml | 30 ++++- scripts/release-asset.sh | 88 +++++++++++++ 4 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 .gitea/workflows/desktop-assets.yml create mode 100755 scripts/release-asset.sh diff --git a/.gitea/workflows/android-apk.yml b/.gitea/workflows/android-apk.yml index f91c042..b625a1a 100644 --- a/.gitea/workflows/android-apk.yml +++ b/.gitea/workflows/android-apk.yml @@ -129,6 +129,18 @@ jobs: fi v="${v#v}" + # v0.0.0 is semantic-release's version floor, not a shipment — + # see the bootstrap step in release.yml. It is skipped cleanly + # rather than failing the guard below, because a 45-minute red + # run against a tag that was never meant to ship is noise, and + # this is the most expensive of the three workflows a tag fires. + if [ "$v" = "0.0.0" ]; then + echo "v0.0.0 is the version floor, not a release; nothing to build" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + # Android orders releases by an integer and refuses anything # not greater than what is installed. 1.3.1 -> 10301, which # increases as long as minor and patch stay below 100. @@ -143,9 +155,24 @@ jobs: echo "version=$v" >> "$GITHUB_OUTPUT" echo "code=$code" >> "$GITHUB_OUTPUT" + echo "tag=v$v" >> "$GITHUB_OUTPUT" echo "building $v (versionCode $code)" + # Releases restarted at 0.0.1 when they became automatic (plan + # 017), so versionCode restarted at 1 — *below* the 10300 an + # installed 1.3.0 build carries. Android refuses a downgrade + # outright, and the only remedy is an uninstall, which takes the + # user's library with it. Said here because this is the file + # that computes the number. + if [ "$code" -lt 10600 ]; then + echo + echo "note: versionCode $code is below the 10600 that v1.6.0 shipped." + echo " An existing install must be removed before this one will" + echo " install, and that removal takes its library with it." + fi + - name: Go toolchain + if: steps.version.outputs.skip == 'false' run: | set -eu if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then @@ -156,6 +183,7 @@ jobs: /cache/tool/go/bin/go version - name: Node toolchain + if: steps.version.outputs.skip == 'false' run: | set -eu curl -fsSL https://deb.nodesource.com/setup_22.x | bash - @@ -167,6 +195,7 @@ jobs: # but still spends minutes verifying, so the guards are what make # this cheap on every run after the first. - name: Android SDK and NDK (cached) + if: steps.version.outputs.skip == 'false' run: | set -eu mkdir -p "$ANDROID_HOME/cmdline-tools" @@ -226,6 +255,7 @@ jobs: # that are *verbatim* a secret, so a trimmed one could print in # clear — or repeating the trimming logic in both. - name: Build the signed APK + if: steps.version.outputs.skip == 'false' working-directory: /src env: KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }} @@ -349,6 +379,7 @@ jobs: - name: Verify the APK id: apk + if: steps.version.outputs.skip == 'false' working-directory: /src run: | set -eu @@ -390,6 +421,7 @@ jobs: # file, so `latest` is deleted first. Credentials are the same # OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with. - name: Publish to the Gitea package registry + if: steps.version.outputs.skip == 'false' working-directory: /src env: VERSION: ${{ steps.version.outputs.version }} @@ -425,3 +457,18 @@ jobs: echo echo "Obtainium URL:" echo " $base/latest/yellowjacket.apk" + + # The generic registry is what Obtainium polls; the release page is + # what a person looks at. Same file, already built and already + # verified by the step above — so this cannot publish something the + # signature check would have refused. + - name: Attach the APK to the release + if: steps.version.outputs.skip == 'false' + working-directory: /src + env: + TAG: ${{ steps.version.outputs.tag }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -eu + ./scripts/release-asset.sh "$TAG" bin/yellowjacket.apk \ + "yellowjacket-${VERSION}-android-arm64.apk" diff --git a/.gitea/workflows/desktop-assets.yml b/.gitea/workflows/desktop-assets.yml new file mode 100644 index 0000000..34db86e --- /dev/null +++ b/.gitea/workflows/desktop-assets.yml @@ -0,0 +1,176 @@ +name: Attach the desktop build to the release + +# The Arch package goes to the pacman registry and the APK to the generic +# one, but a release page with nothing on it to download is a release page +# nobody can use. This builds the plain Linux x86_64 binary and attaches +# it, so "get the latest version" has an answer that needs no package +# manager at all. +# +# **Linux only, and macOS is not an oversight.** `GOOS=darwin +# CGO_ENABLED=0` fails at `wails/v3/pkg/mac: build constraints exclude all +# Go files` — the darwin backend is Objective-C behind cgo, so a .app +# needs a macOS host, and the runner is a Linux container. That is +# exactly why the Homebrew formula builds from source on the user's own +# Mac, and it stays the macOS channel. +# +# Windows *does* cross-compile (GOOS=windows CGO_ENABLED=0 succeeds in a +# couple of seconds — nothing in the audio, database or webview path needs +# cgo there), and is deliberately not published: no Windows build of this +# app has ever been run, and no tier here can exercise one. Shipping it +# would be a promise nothing in this repo can keep. Revisit when someone +# has actually booted it. + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + version: + description: "Version to build and attach (default: the latest v* tag)" + required: false + +concurrency: + group: desktop-assets-${{ github.ref }} + cancel-in-progress: true + +jobs: + linux: + runs-on: ubuntu-latest + container: + image: ubuntu:24.04 + volumes: + - /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool + - /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store + env: + PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + REF_NAME: ${{ github.ref_name }} + DEBIAN_FRONTEND: noninteractive + GO_VERSION: '1.25.0' + npm_config_store_dir: /cache/pnpm-store + steps: + # The same set ci.yml's check job installs: the app is cgo, and + # without alsa.pc oto/v3 fails at `pkg-config --cflags -- alsa` + # before anything is compiled. + - name: System packages + run: | + set -eu + apt-get update -qq + apt-get install -y -qq --no-install-recommends \ + ca-certificates curl git jq build-essential pkg-config \ + libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev + + - name: Clone repo at this commit + run: | + set -eu + git clone --quiet \ + "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src + git -C /src checkout --quiet --detach "$SHA" + git config --global --add safe.directory /src + git -C /src log --oneline -1 + + - name: Resolve the version + id: version + working-directory: /src + run: | + set -eu + v="${{ inputs.version }}" + if [ -z "$v" ]; then + case "$REF_NAME" in + v*) v="$REF_NAME" ;; + *) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*') ;; + esac + fi + case "$v" in v*) ;; *) v="v$v" ;; esac + + # v0.0.0 is semantic-release's version floor, not a shipment — + # see the bootstrap step in release.yml. Nothing is built for + # it, and this is a clean skip rather than a failure because a + # red run against a tag that was never meant to ship is noise. + if [ "$v" = "v0.0.0" ]; then + echo "v0.0.0 is the version floor, not a release; nothing to build" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "tag=$v" >> "$GITHUB_OUTPUT" + echo "version=${v#v}" >> "$GITHUB_OUTPUT" + echo "building $v" + + - name: Go toolchain + if: steps.version.outputs.skip == 'false' + run: | + set -eu + if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then + mkdir -p /cache/tool && rm -rf /cache/tool/go + curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz + fi + echo "/cache/tool/go/bin" >> "$GITHUB_PATH" + /cache/tool/go/bin/go version + + - name: Node toolchain + if: steps.version.outputs.skip == 'false' + run: | + set -eu + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y -qq --no-install-recommends nodejs + corepack enable + node --version + + # `make build-prod` is the production task: -trimpath and -w -s are + # already in it, so only the version stamp is passed, through the + # LDFLAGS_EXTRA variable this repo added to build/linux/Taskfile.yml. + # (`wails3 build` has no -ldflags of its own; that was v2.) + - name: Build + if: steps.version.outputs.skip == 'false' + working-directory: /src + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + set -eu + export PATH="/src/scripts/toolbin:$PATH" + commit=$(git rev-parse --short HEAD) + + go generate ./... + go tool wails3 task build \ + LDFLAGS_EXTRA="-X 'main.version=${TAG}' -X 'main.commit=${commit}'" + + # Described, never run: main.go has no flag parsing, so any + # invocation here would try to open a window in a container with + # no display and hang the job rather than printing a version. + test -x bin/yellowjacket + ls -la bin/yellowjacket + file bin/yellowjacket || true + + # The .desktop file and the icon go in the tarball because without + # them the binary is a window with no menu entry — the Arch package + # installs both, and this is the same app for people not using it. + - name: Package the tarball + if: steps.version.outputs.skip == 'false' + working-directory: /src + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -eu + dir="yellowjacket-${VERSION}-linux-amd64" + mkdir -p "/tmp/$dir" + cp bin/yellowjacket "/tmp/$dir/" + cp packaging/arch/yellowjacket.desktop "/tmp/$dir/" + cp frontend/src/assets/images/icons/music/compact-disc.svg \ + "/tmp/$dir/yellowjacket.svg" + tar -C /tmp -czf "/tmp/${dir}.tar.gz" "$dir" + ls -la "/tmp/${dir}.tar.gz" + + - name: Attach it to the release + if: steps.version.outputs.skip == 'false' + working-directory: /src + env: + TAG: ${{ steps.version.outputs.tag }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -eu + ./scripts/release-asset.sh "$TAG" \ + "/tmp/yellowjacket-${VERSION}-linux-amd64.tar.gz" diff --git a/.gitea/workflows/homebrew-formula.yml b/.gitea/workflows/homebrew-formula.yml index 0b70221..c19d14e 100644 --- a/.gitea/workflows/homebrew-formula.yml +++ b/.gitea/workflows/homebrew-formula.yml @@ -14,6 +14,15 @@ on: push: tags: - "v*" + workflow_dispatch: + inputs: + version: + description: "Version to sync (default: the pushed tag)" + required: false + +concurrency: + group: homebrew-${{ github.ref }} + cancel-in-progress: true jobs: sync-formula: @@ -30,10 +39,25 @@ jobs: uses: actions/checkout@v4 - name: Compute version and tarball checksum + id: version run: | set -euo pipefail - TAG="${GITHUB_REF_NAME}" # e.g. v1.3.0 - VERSION="${TAG#v}" # e.g. 1.3.0 + TAG="${{ inputs.version }}" + [ -n "$TAG" ] || TAG="${GITHUB_REF_NAME}" # e.g. v0.0.1 + case "$TAG" in v*) ;; *) TAG="v$TAG" ;; esac + VERSION="${TAG#v}" # e.g. 0.0.1 + + # v0.0.0 is semantic-release's version floor, not a shipment — + # see the bootstrap step in release.yml. Skipped cleanly rather + # than failing: this one would otherwise push a formula for a + # version that does not exist into a *public* tap. + if [ "$VERSION" = "0.0.0" ]; then + echo "v0.0.0 is the version floor, not a release; nothing to sync" + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skip=false" >> "$GITHUB_OUTPUT" + TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz" echo "Fetching ${TARBALL}" @@ -53,6 +77,7 @@ jobs: echo "SHA256=${SHA256}" >> "$GITHUB_ENV" - name: Render the formula with the new version and checksum + if: steps.version.outputs.skip == 'false' run: | set -euo pipefail src="packaging/homebrew/Formula/yellowjacket.rb" @@ -66,6 +91,7 @@ jobs: cat yellowjacket.rb - name: Push to the Homebrew tap repo + if: steps.version.outputs.skip == 'false' run: | set -euo pipefail git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap diff --git a/scripts/release-asset.sh b/scripts/release-asset.sh new file mode 100755 index 0000000..474b58a --- /dev/null +++ b/scripts/release-asset.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# +# Attach a built artifact to the Gitea release for a tag. +# +# **It waits for the release to exist, and that is the point of the +# file.** semantic-release pushes the tag in its `prepare` step and +# creates the release object in `publish` — so the tag push, which is +# what starts every publishing workflow, happens *before* there is a +# release id to upload to. A fast publisher can therefore arrive first. +# +# The runner has capacity 1, which serialises things enough that this +# would usually work by accident; that is the worst kind of bug, so the +# wait is explicit and a timeout is a loud failure rather than a silently +# skipped asset. +# +# Usage: scripts/release-asset.sh [upload-name] +# +# Environment: +# SERVER_URL https://git.ljones.me +# REPO yonlu/yellowjacket +# PACKAGE_TOKEN a user PAT with write access +set -euo pipefail + +tag="${1:?usage: release-asset.sh [name]}" +file="${2:?usage: release-asset.sh [name]}" +name="${3:-$(basename "$file")}" + +: "${SERVER_URL:?SERVER_URL is not set}" +: "${REPO:?REPO is not set}" +: "${PACKAGE_TOKEN:?PACKAGE_TOKEN is not set}" + +[ -s "$file" ] || { echo "release-asset: $file is missing or empty" >&2; exit 1; } + +auth="Authorization: token ${PACKAGE_TOKEN}" +api="${SERVER_URL}/api/v1/repos/${REPO}" + +# Up to five minutes. A release that has not appeared by then means the +# release job failed, and this should say so rather than time out quietly. +release_id="" +for attempt in $(seq 1 60); do + release_id=$(curl -sS -H "$auth" "${api}/releases/tags/${tag}" | + jq -r 'if type == "object" and has("id") then .id else empty end') + + if [ -n "$release_id" ]; then + echo "release-asset: release for $tag is id $release_id (after ${attempt} check(s))" + break + fi + + [ "$attempt" -eq 1 ] && echo "release-asset: waiting for the release for $tag to be created" + sleep 5 +done + +if [ -z "$release_id" ]; then + echo "release-asset: no release for $tag after 5 minutes." >&2 + echo " The tag is pushed in semantic-release's prepare step and the release" >&2 + echo " is created in publish, so this means the release job did not get that" >&2 + echo " far. Check the run of release.yml for this commit." >&2 + exit 1 +fi + +# Gitea refuses a duplicate asset name rather than replacing it, so a +# re-run of the same tag deletes the old one first. That keeps a manual +# workflow_dispatch rebuild idempotent, which is the only reason anyone +# re-runs one of these. +existing=$(curl -sS -H "$auth" "${api}/releases/${release_id}/assets" | + jq -r --arg n "$name" '.[]? | select(.name == $n) | .id') + +if [ -n "$existing" ]; then + echo "release-asset: replacing the existing '$name' (asset $existing)" + curl -sS -o /dev/null -H "$auth" -X DELETE \ + "${api}/releases/${release_id}/assets/${existing}" +fi + +echo "release-asset: uploading $name ($(du -h "$file" | cut -f1))" + +code=$(curl -sS -o /tmp/release-asset.out -w '%{http_code}' \ + -H "$auth" \ + -X POST \ + -F "attachment=@${file};filename=${name}" \ + "${api}/releases/${release_id}/assets?name=${name}") + +if [ "$code" != "201" ]; then + echo "release-asset: upload returned $code" >&2 + cat /tmp/release-asset.out >&2 + exit 1 +fi + +echo "release-asset: attached $name to $tag"