From 12cfca8b17d4aa9270e6698b526fc6116afd765a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 4 Oct 2026 12:17:46 -0700 Subject: [PATCH] Publish a fixed-URL "latest" AppImage for static-URL updaters Gitea has no /releases/latest/download/ redirect and release URLs always include the tag, so updaters that poll a constant URL (Shelly) cannot use them. After publishing a vX.Y.Z release, replace the asset on a rolling pre-release tagged "latest": .../releases/download/latest/WhatsThatMusic-latest-linux-x86_64.AppImage Replacing the asset changes its ETag and Last-Modified, which Shelly compares. The pre-release flag keeps Gitea's own "latest release" on the newest vX.Y.Z. The logic lives in .gitea/scripts/update-latest.sh so it can be run against the API outside CI. The publish job now checks out first, since checkout cleans the workspace and would delete downloaded artifacts. Co-Authored-By: Claude Sonnet 5.5 --- .gitea/scripts/update-latest.sh | 75 +++++++++++++++++++++++++++++++++ .gitea/workflows/release.yml | 12 ++++++ 2 files changed, 87 insertions(+) create mode 100755 .gitea/scripts/update-latest.sh diff --git a/.gitea/scripts/update-latest.sh b/.gitea/scripts/update-latest.sh new file mode 100755 index 0000000..0580042 --- /dev/null +++ b/.gitea/scripts/update-latest.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Maintains a rolling "latest" release whose AppImage always has the same +# download URL, for updaters that poll a static URL (e.g. Shelly) instead of +# understanding version tags: +# +# ///releases/download/latest/WhatsThatMusic-latest-linux-x86_64.AppImage +# +# Gitea serves ETag and Last-Modified on release assets, and replacing the +# asset changes both, which is how those updaters detect a new build. +# Gitea has no GitHub-style /releases/latest/download/ redirect, hence this. +# +# Marked as a pre-release so Gitea's own "latest release" stays the newest vX.Y.Z. +# +# Required env: TOKEN, API (…/api/v1/repos//), GITHUB_REF_NAME, GITHUB_SHA +# Optional env: ARTIFACTS_DIR (default: artifacts) +set -euo pipefail + +: "${TOKEN:?TOKEN is required}" "${API:?API is required}" +: "${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}" "${GITHUB_SHA:?GITHUB_SHA is required}" +ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}" +LATEST_TAG="latest" +LATEST_NAME="WhatsThatMusic-latest-linux-x86_64.AppImage" + +api() { + local method="$1" path="$2"; shift 2 + local out code body + out=$(curl -s -w '\n%{http_code}' -X "$method" -H "Authorization: token $TOKEN" "$API$path" "$@") + code=$(printf '%s' "$out" | tail -n1) + body=$(printf '%s' "$out" | sed '$d') + if [ "$code" -ge 400 ]; then + echo "$method $path -> HTTP $code" >&2 + echo "$body" >&2 + return 1 + fi + printf '%s' "$body" +} + +appimage=$(find "$ARTIFACTS_DIR" -type f -name '*.AppImage' | sort | head -n1) +if [ -z "$appimage" ]; then + echo "No AppImage under $ARTIFACTS_DIR; cannot update the $LATEST_TAG release." >&2 + exit 1 +fi + +body_json=$(node -e ' + console.log(JSON.stringify({ + body: "Rolling pointer to the newest AppImage, currently " + process.argv[1] + ". " + + "The download URL for this asset never changes; versioned downloads are on each vX.Y.Z release.", + }));' "$GITHUB_REF_NAME") + +rolling_id=$(api GET "/releases/tags/$LATEST_TAG" 2>/dev/null \ + | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id" 2>/dev/null || true) + +if [ -z "${rolling_id:-}" ]; then + echo "No '$LATEST_TAG' release yet; creating it." + create_json=$(node -e ' + console.log(JSON.stringify({ + tag_name: "latest", name: "Latest (rolling)", target_commitish: process.argv[1], + prerelease: true, body: JSON.parse(process.argv[2]).body, + }));' "$GITHUB_SHA" "$body_json") + rolling_id=$(api POST "/releases" -H "Content-Type: application/json" -d "$create_json" \ + | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).id") +else + api PATCH "/releases/$rolling_id" -H "Content-Type: application/json" -d "$body_json" >/dev/null +fi + +# Replace the asset: the new upload gets a new ETag and Last-Modified. +old_id=$(api GET "/releases/$rolling_id/assets" \ + | node -pe "((JSON.parse(require('fs').readFileSync(0,'utf8'))).find(a => a.name === process.argv[1]) || {}).id || ''" "$LATEST_NAME") +if [ -n "$old_id" ]; then + api DELETE "/releases/$rolling_id/assets/$old_id" >/dev/null +fi + +echo "Uploading $LATEST_NAME from $(basename "$appimage") ($(du -h "$appimage" | cut -f1))" +api POST "/releases/$rolling_id/assets?name=$LATEST_NAME" -F "attachment=@$appimage" >/dev/null +echo "Updated '$LATEST_TAG' release ($rolling_id) to $GITHUB_REF_NAME" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 596bb58..2ada30b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -129,6 +129,9 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: + # Checkout first: it cleans the workspace, which would delete downloaded artifacts. + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v3 with: path: artifacts @@ -198,3 +201,12 @@ jobs: exit 1 fi echo "Attached $found artifacts to release $release_id" + + # Constant-URL copy of the AppImage for updaters that poll a static URL + # (e.g. Shelly). Runs after the versioned release exists, so a failure here + # cannot leave a tag without its assets. + - name: Update rolling "latest" AppImage + env: + TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + run: bash .gitea/scripts/update-latest.sh