From f9ea7fcdc29d4287b17b042f7238e86a35f87be6 Mon Sep 17 00:00:00 2001 From: Anibal Ribeiro Date: Fri, 4 Sep 2026 12:41:35 +0200 Subject: [PATCH] Build the appcast download URL from the release tag The enclosure URL was hardcoded to releases/download/v, but a workflow_dispatch run publishes under manual-. Checking publish on a manual run would therefore have produced a feed pointing at a URL that does not exist. Worse, that feed would not have stayed contained. GitHub serves releases/latest/download/appcast.xml from whichever release is newest, and the manual release was created as a normal one, so it would have become "latest" and handed a broken feed to every installed copy of the app. The Homebrew step had the same problem from the other direction: the cask URL is built from v, which does not exist under a manual tag. So the tag is now a required argument rather than something derived from the version, and manual publishes are constrained to be prereleases with the tap left alone. Regenerating the live 1.1.0 feed with this change produces a byte-identical result, so tag releases are unaffected. Co-authored-by: Cursor --- .github/workflows/release.yml | 20 ++++++++++++++++-- docs/appcast-template.xml | 2 +- docs/releasing.md | 6 ++++++ scripts/generate-appcast.sh | 12 ++++++++--- scripts/test-generate-appcast.sh | 36 ++++++++++++++++++++++++++++---- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32b8892..4cb1c88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,10 @@ jobs: VERSION="${DISPATCH_VERSION}" TAG="manual-${VERSION}" PUBLISH="${{ inputs.publish }}" + # releases/latest/download/appcast.xml is the feed every installed + # copy reads. A manual run must land as a prerelease so it can never + # become "latest" and offer a test build to all users. + PRERELEASE=true else TAG="${GITHUB_REF_NAME}" case "$TAG" in @@ -42,6 +46,7 @@ jobs: *) echo "Tag must start with v" >&2; exit 1 ;; esac PUBLISH=true + PRERELEASE=false fi case "$VERSION" in "") echo "Version must be non-empty" >&2; exit 1 ;; @@ -49,6 +54,7 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "publish=$PUBLISH" >> "$GITHUB_OUTPUT" + echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" - name: Require a changelog for this version env: @@ -187,6 +193,7 @@ jobs: - name: Generate appcast env: VERSION: ${{ steps.ver.outputs.version }} + TAG: ${{ steps.ver.outputs.tag }} ZIP: ${{ steps.sum.outputs.zip }} SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} run: | @@ -194,7 +201,7 @@ jobs: umask 077 printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$KEY_FILE" SPARKLE_ED_KEY_FILE="$KEY_FILE" \ - ./scripts/generate-appcast.sh "$VERSION" "$ZIP" "docs/changelog/${VERSION}.md" + ./scripts/generate-appcast.sh "$VERSION" "$ZIP" "docs/changelog/${VERSION}.md" "$TAG" rm -f "$KEY_FILE" - name: Upload workflow artifacts (dry run / debugging) @@ -222,9 +229,15 @@ jobs: VERSION: ${{ steps.ver.outputs.version }} ZIP: ${{ steps.sum.outputs.zip }} DMG: ${{ steps.sum.outputs.dmg }} + PRERELEASE: ${{ steps.ver.outputs.prerelease }} run: | if gh release view "$TAG" >/dev/null 2>&1; then gh release upload "$TAG" "$ZIP" "$DMG" dist/appcast.xml --clobber + elif [ "$PRERELEASE" = "true" ]; then + gh release create "$TAG" "$ZIP" "$DMG" dist/appcast.xml \ + --title "WinMice $VERSION" \ + --notes-file /tmp/release-notes.md \ + --prerelease else gh release create "$TAG" "$ZIP" "$DMG" dist/appcast.xml \ --title "WinMice $VERSION" \ @@ -233,7 +246,10 @@ jobs: - name: Update Homebrew tap id: tap - if: steps.ver.outputs.publish == 'true' + # Skipped for manual runs: the cask URL is built from v, which + # does not exist under a manual- tag, so bumping it would point + # every Homebrew user at a 404. + if: steps.ver.outputs.publish == 'true' && steps.ver.outputs.prerelease != 'true' continue-on-error: true env: HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} diff --git a/docs/appcast-template.xml b/docs/appcast-template.xml index 311e746..271c8a5 100644 --- a/docs/appcast-template.xml +++ b/docs/appcast-template.xml @@ -16,7 +16,7 @@ - diff --git a/docs/releasing.md b/docs/releasing.md index ff8723d..3ed8478 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -164,6 +164,12 @@ Release or bumping Homebrew: Only then cut a real `vX.Y.Z` tag. +A manual run tags `manual-` rather than `v`. If you do check +**publish** on one, it is deliberately constrained so it cannot be mistaken for +a real release: the GitHub Release is created as a **prerelease**, so it never +becomes `releases/latest` and the appcast there is never served to installed +copies, and the Homebrew cask is left alone. Real releases come from tag pushes. + ## Release checklist 1. Ensure the Apple, Homebrew, and Sparkle prerequisites above are set, and a diff --git a/scripts/generate-appcast.sh b/scripts/generate-appcast.sh index c0fd06c..47e37cf 100755 --- a/scripts/generate-appcast.sh +++ b/scripts/generate-appcast.sh @@ -1,9 +1,13 @@ #!/bin/sh set -eu -VERSION="${1:?usage: generate-appcast.sh }" -ZIP="${2:?usage: generate-appcast.sh }" -CHANGELOG="${3:?usage: generate-appcast.sh }" +VERSION="${1:?usage: generate-appcast.sh }" +ZIP="${2:?usage: generate-appcast.sh }" +CHANGELOG="${3:?usage: generate-appcast.sh }" +# The tag is required rather than derived from the version: releases are not +# always tagged v, and an enclosure URL built from the wrong tag sends +# every user to a 404. +TAG="${4:?usage: generate-appcast.sh }" KEY_FILE="${SPARKLE_ED_KEY_FILE:?SPARKLE_ED_KEY_FILE must point at the exported EdDSA private key}" TEMPLATE="docs/appcast-template.xml" OUT="dist/appcast.xml" @@ -45,6 +49,7 @@ PUBDATE=$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000') mkdir -p dist awk -v version="$VERSION" \ + -v tag="$TAG" \ -v sig="$ED_SIGNATURE" \ -v len="$LENGTH" \ -v pubdate="$PUBDATE" \ @@ -56,6 +61,7 @@ awk -v version="$VERSION" \ } { gsub(/\{\{VERSION\}\}/, version) + gsub(/\{\{TAG\}\}/, tag) gsub(/\{\{ED_SIGNATURE\}\}/, sig) gsub(/\{\{LENGTH\}\}/, len) gsub(/\{\{PUBDATE\}\}/, pubdate) diff --git a/scripts/test-generate-appcast.sh b/scripts/test-generate-appcast.sh index fcf162e..837af19 100755 --- a/scripts/test-generate-appcast.sh +++ b/scripts/test-generate-appcast.sh @@ -24,7 +24,7 @@ export SPARKLE_ED_KEY_FILE="$TMP/key" if ! ( cd "$TMP/project" - "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md + "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md v9.9.9 ) >"$TMP/stdout" 2>"$TMP/stderr"; then printf 'generate-appcast unexpectedly failed\n' >&2 cat "$TMP/stderr" >&2 @@ -57,12 +57,40 @@ fi # Missing changelog must fail rather than emit an appcast with empty notes. if ( cd "$TMP/project" - "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/nope.md + "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/nope.md v9.9.9 ) >/dev/null 2>&1; then echo "expected generate-appcast to fail on a missing changelog" >&2 exit 1 fi +# The download URL must come from the tag the release is actually published +# under, not from the version. A workflow_dispatch run tags manual-, +# and an appcast pointing at v would send every user to a 404. +if ! ( + cd "$TMP/project" + "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md manual-9.9.9 +) >/dev/null 2>"$TMP/tag-stderr"; then + printf 'generate-appcast failed for a manual- tag\n' >&2 + cat "$TMP/tag-stderr" >&2 + exit 1 +fi + +grep -q 'releases/download/manual-9.9.9/WinMice-9.9.9.zip' "$OUT" +if grep -q 'releases/download/v9.9.9/' "$OUT"; then + echo "enclosure URL ignored the tag and used the version instead" >&2 + exit 1 +fi + +# The tag must be supplied explicitly. Defaulting it to v is what +# allowed the mismatch in the first place, so absence is an error. +if ( + cd "$TMP/project" + "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md +) >/dev/null 2>&1; then + echo "expected generate-appcast to require an explicit tag" >&2 + exit 1 +fi + # sign_update discovery must pick the EdDSA tool by name, not by find's # traversal order, and must never fall back to the deprecated DSA script. DISCOVERY_ROOT="$TMP/discovery" @@ -91,7 +119,7 @@ chmod +x "$DISCOVERY_ROOT/project/.build/artifacts/sparkle/Sparkle/bin/sign_upda if ! ( cd "$DISCOVERY_ROOT/project" - env -u SPARKLE_SIGN_UPDATE "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md + env -u SPARKLE_SIGN_UPDATE "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md v9.9.9 ) >"$TMP/discovery-stdout" 2>"$TMP/discovery-stderr"; then printf 'generate-appcast unexpectedly failed while discovering sign_update\n' >&2 cat "$TMP/discovery-stderr" >&2 @@ -119,7 +147,7 @@ chmod +x "$ZERO_ROOT/project/.build/artifacts/sparkle/Sparkle/bin/old_dsa_script if ( cd "$ZERO_ROOT/project" - env -u SPARKLE_SIGN_UPDATE "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md + env -u SPARKLE_SIGN_UPDATE "$ROOT/scripts/generate-appcast.sh" 9.9.9 dist/WinMice-9.9.9.zip docs/changelog-9.9.9.md v9.9.9 ) >"$TMP/zero-stdout" 2>"$TMP/zero-stderr"; then echo "expected generate-appcast to fail when no EdDSA sign_update is found" >&2 exit 1