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