mudlet/.github/workflows/create-github-release.yml
Vadim Peretokin 71f736297b
infrastructure: reject a release tag that does not match APP_VERSION (#9701)
#### Brief overview of PR changes/additions
- Adds `CI/check-release-tag.sh`: APP_VERSION must be three-component,
and a release tag must be exactly `Mudlet-<APP_VERSION>`.
- Wires it into the tag-build validation (`CI/validate_deployment.sh`,
`CI/validate-deployment-for-windows.sh`) so a bad tag fails minutes
after the push, before an asset exists, and into
`create-github-release.yml` as the last gate before anything is
published. The PTB path gets the version-shape half, which nothing
checks on `development` today.
- Covers it with `test/ci/release-tag-version-test.sh`, registered as
`ReleaseTagVersionTest`.

#### Motivation for adding to Mudlet
Tagging `Mudlet-5.0` instead of `Mudlet-5.0.0` would strand the entire
4.22.0 user base with no error anywhere, and it is the one version
mistake CI does not currently catch.

The updater takes the version it offers from the tag, not the binary:
`Release::Release()` strips the `Mudlet-` prefix
(`src/updater/Release.cpp:49`) and `SemVer::getRegExp()` needs three
components (`src/updater/SemVer.cpp:111`), so `"5.0"` is invalid,
`Release::operator<` (`src/updater/Release.cpp:96`) reports the release
as not newer, and `Feed::getUpdates()` returns nothing. The update check
goes on logging `0 update(s) available` - the same line as a week with
no release.

The asymmetry is what makes it dangerous. A stale APP_VERSION with a
correct tag fails loudly, because `CI/prepare-release-assets.sh:62`
rejects assets by tag prefix. A short tag with a correct APP_VERSION
passes everything, because `Mudlet-5.0.0-linux-x64.AppImage.tar`
genuinely does start with `Mudlet-5.0`.

**Why the build scripts and not only the workflow:**
`create-github-release.yml` is `workflow_run`-triggered, so it cannot
fail before the assets are built - by the time it runs, the full matrix
has already finished. The validate scripts run at the start of every tag
build on all three platforms and already parse APP_VERSION, so that is
where the fast failure belongs. The workflow keeps a copy because it
always runs from the default branch, so it still guards a tag placed on
a commit that predates this change.

APP_VERSION is deliberately left at 4.22.0 - bumping it is a release
decision, not a QA fix. This guard is what catches a mismatch when the
bump happens.

#### Other info (issues closed, discussion etc)
From the 5.0 release QA sweep, finding C1, "A two-component release tag
silently disables auto-update for every existing user". Pre-existing
mechanism, no single commit introduced it.

Three claims from an earlier draft did not survive checking and were
corrected: `src/sparkleupdater.mm` installs no
`versionComparatorForUpdater:`, so Sparkle's default component-wise
comparator would still offer `5.0` over `4.22.0` (macOS breaks on the
opposite mismatch instead); the update check does log, it is just
indistinguishable from having nothing to offer; and SemVer does accept a
prerelease component, so rejecting `Mudlet-5.0.0-rc1` follows from
APP_VERSION being unable to carry a suffix, not from the updater.

No video - a CI guard is not visually observable. The shell output below
is the evidence instead.

**Test case:** `ctest -R ReleaseTagVersionTest`, and the guard run
directly:

```
$ CI/check-release-tag.sh 5.0.0 Mudlet-5.0.0
Release tag 'Mudlet-5.0.0' matches APP_VERSION '5.0.0'.
exit=0

$ CI/check-release-tag.sh 5.0.0 Mudlet-5.0
error: release tag 'Mudlet-5.0' does not match APP_VERSION '5.0.0'.
The tag has to be exactly 'Mudlet-5.0.0'.

Publishing under a mismatched tag breaks auto-update, without saying so. [...]
exit=1
```

Replayed over every release tag since 4.18.5, each against the
APP_VERSION at that tag - all accepted, so the guard blocks nothing
Mudlet has actually shipped. Executing the real `Determine release type`
step under GitHub's shell flags fails on `Mudlet-5.0` + `5.0.0` and on a
PTB with APP_VERSION `5.0`, and passes on `Mudlet-5.0.0` + `5.0.0` and
on a normal PTB. The updater trace was confirmed by compiling
`Release.cpp` + `SemVer.cpp` and comparing: tag `Mudlet-5.0.0` gives
`(4.22.0 < release) = true`, tag `Mudlet-5.0` gives `false`.

Assisted-by: Claude:claude-opus-5
2026-08-07 06:14:11 +02:00

581 lines
26 KiB
YAML

name: 📦 Create GitHub Release
on:
workflow_run:
workflows: ["🔨 Build Mudlet", "🔨 Build Mudlet (windows)"]
types: [completed]
branches: [master, development, release-*, Mudlet-*]
permissions:
contents: write
actions: read
# Both build workflows trigger this; don't cancel the first - the second trigger completes the release
concurrency:
group: create-github-release-${{ github.event.workflow_run.head_sha }}
cancel-in-progress: false
jobs:
create-release:
runs-on: ubuntu-latest
if: >
github.repository_owner == 'Mudlet' &&
github.event.workflow_run.conclusion == 'success'
steps:
- name: Check if platform builds completed
id: check
uses: actions/github-script@v9
with:
script: |
const triggeringRun = context.payload.workflow_run;
if (triggeringRun.event === 'pull_request') {
core.info(`Skipping: triggered by a pull_request build`);
core.setOutput('ready', 'false');
return;
}
const sha = triggeringRun.head_sha;
const platforms = {
'linux_macos': 'build-mudlet.yml',
'windows': 'build-mudlet-win.yml'
};
const runIds = {};
for (const [key, filename] of Object.entries(platforms)) {
const {data} = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: filename,
head_sha: sha,
status: 'success',
event: triggeringRun.event,
per_page: 1
});
if (data.total_count === 0) {
core.info(`Workflow "${filename}" has not completed successfully yet for ${sha}`);
} else {
runIds[key] = data.workflow_runs[0].id;
}
}
const totalPlatforms = Object.keys(platforms).length;
const completedCount = Object.keys(runIds).length;
if (completedCount === 0) {
core.info('No platform builds have succeeded yet');
core.setOutput('ready', 'false');
return;
}
// Release builds must wait for all platforms; PTBs can proceed with partial
if (completedCount < totalPlatforms && triggeringRun.head_branch !== 'development') {
core.info(`Only ${completedCount}/${totalPlatforms} platforms completed - waiting for all platforms (release build)`);
core.setOutput('ready', 'false');
return;
}
core.setOutput('ready', 'true');
core.setOutput('linux_macos_run_id', runIds['linux_macos'] || '');
core.setOutput('windows_run_id', runIds['windows'] || '');
# Checkout early so .git exists before artifact downloads — actions/checkout
# wipes the workspace when no .git directory is present, even with clean: false
- uses: actions/checkout@v7
if: steps.check.outputs.ready == 'true'
with:
ref: ${{ github.event.workflow_run.head_sha }}
fetch-depth: 0
# workflow_run always runs this file from the default branch, while the
# checkout above is the commit that was built - which may predate the release
# scripts below. Take them from the same ref as this workflow file.
- uses: actions/checkout@v7
if: steps.check.outputs.ready == 'true'
with:
ref: ${{ github.workflow_sha }}
path: release-scripts
fetch-depth: 1
- uses: leafo/gh-actions-lua@v13
if: steps.check.outputs.ready == 'true'
with:
luaVersion: "5.1.5"
- uses: leafo/gh-actions-luarocks@v6
if: steps.check.outputs.ready == 'true'
- name: Install changelog dependencies
if: steps.check.outputs.ready == 'true'
run: |
luarocks install argparse
luarocks install lunajson
# Download build metadata to determine if this is a release or PTB build (not present for snapshot/PR builds)
- name: Download metadata from Linux/macOS build
if: steps.check.outputs.ready == 'true' && steps.check.outputs.linux_macos_run_id != ''
id: download-metadata-lm
uses: actions/download-artifact@v8
continue-on-error: true
with:
pattern: release-metadata-*
run-id: ${{ steps.check.outputs.linux_macos_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: metadata/
- name: Download metadata from Windows build
if: steps.check.outputs.ready == 'true' && steps.download-metadata-lm.outcome != 'success' && steps.check.outputs.windows_run_id != ''
id: download-metadata-win
uses: actions/download-artifact@v8
continue-on-error: true
with:
pattern: release-metadata-*
run-id: ${{ steps.check.outputs.windows_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: metadata/
- name: Report skipped release
if: steps.check.outputs.ready == 'true' && steps.download-metadata-lm.outcome != 'success' && steps.download-metadata-win.outcome != 'success'
run: echo "::warning::Release metadata download failed - cannot determine release type"
- name: Determine release type
if: steps.check.outputs.ready == 'true' && (steps.download-metadata-lm.outcome == 'success' || steps.download-metadata-win.outcome == 'success')
id: release-type
run: |
META_FILE=$(find metadata/ -name 'release-metadata.json' -type f | head -1)
if [[ -z "${META_FILE}" ]]; then
echo "No release metadata found - this is likely a snapshot or PR build"
echo "type=skip" >> "$GITHUB_OUTPUT"
exit 0
fi
META=$(cat "${META_FILE}")
REF=$(echo "$META" | jq -r '.ref')
EVENT=$(echo "$META" | jq -r '.event')
VERSION=$(echo "$META" | jq -r '.version')
BUILD_SUFFIX=$(echo "$META" | jq -r '.build_suffix')
if [[ -z "${VERSION}" || "${VERSION}" == "null" ]]; then
echo "::error::VERSION field is empty or missing in release metadata"
exit 1
fi
if [[ -z "${REF}" || "${REF}" == "null" ]]; then
echo "::error::REF field is empty or missing in release metadata"
exit 1
fi
COMMIT=$(echo "$META" | jq -r '.commit')
if [[ "$REF" == refs/tags/Mudlet-* ]]; then
# release-scripts/ is this workflow's own ref, so the guard is present
# even when the tagged commit predates it
bash release-scripts/CI/check-release-tag.sh "${VERSION}" "${REF#refs/tags/}"
echo "type=release" >> "$GITHUB_OUTPUT"
echo "tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
echo "title=Mudlet ${VERSION}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
elif [[ "$EVENT" == "schedule" ]] || [[ "$BUILD_SUFFIX" == -ptb* ]]; then
if [[ -z "${COMMIT}" || "${COMMIT}" == "null" ]]; then
echo "::error::COMMIT field is empty or missing in release metadata for PTB"
exit 1
fi
# Nothing validates APP_VERSION on development, and a two-component one
# makes a PTB unofferable the same way
bash release-scripts/CI/check-release-tag.sh "${VERSION}"
PTB_TAG="Mudlet-${VERSION}${BUILD_SUFFIX}-${COMMIT}"
echo "type=ptb" >> "$GITHUB_OUTPUT"
echo "tag=${PTB_TAG}" >> "$GITHUB_OUTPUT"
echo "title=Public Test Build" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
else
echo "type=skip" >> "$GITHUB_OUTPUT"
fi
# Download all release assets from both workflow runs
- name: Download Linux/macOS release assets
if: (steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb') && steps.check.outputs.linux_macos_run_id != ''
id: download-linux-macos
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: release-asset-*
run-id: ${{ steps.check.outputs.linux_macos_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: assets/
- name: Download Windows release assets
if: (steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb') && steps.check.outputs.windows_run_id != ''
id: download-windows
continue-on-error: true
uses: actions/download-artifact@v8
with:
pattern: release-asset-*
run-id: ${{ steps.check.outputs.windows_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: assets/
# A download that fails leaves the release short of a platform, so surface it
# rather than letting continue-on-error hide it
- name: Report asset download failures
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
env:
LINUX_MACOS_OUTCOME: ${{ steps.download-linux-macos.outcome }}
WINDOWS_OUTCOME: ${{ steps.download-windows.outcome }}
run: |
if [[ "${LINUX_MACOS_OUTCOME}" == "failure" ]]; then
echo "::warning::Downloading the Linux/macOS release assets failed - they will be missing from this release"
fi
if [[ "${WINDOWS_OUTCOME}" == "failure" ]]; then
echo "::warning::Downloading the Windows release assets failed - it will be missing from this release"
fi
- name: Prepare release assets
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
env:
RELEASE_TAG: ${{ steps.release-type.outputs.tag }}
RELEASE_TYPE: ${{ steps.release-type.outputs.type }}
run: bash release-scripts/CI/prepare-release-assets.sh assets/ "${RELEASE_TAG}" "${RELEASE_TYPE}"
# The release may already carry assets from the other build workflow's run of
# this job, so its SHA256SUMS.txt has to be merged rather than overwritten
- name: Fetch published SHA256SUMS.txt
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
env:
RELEASE_TAG: ${{ steps.release-type.outputs.tag }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p published/
if ! gh release view "${RELEASE_TAG}" 2> view-error.txt; then
if grep -qi 'release not found' view-error.txt; then
echo "Release ${RELEASE_TAG} does not exist yet - no checksums to merge"
exit 0
fi
cat view-error.txt
echo "::error::Could not read release ${RELEASE_TAG} - refusing to rebuild its checksums from a partial view"
exit 1
fi
if gh release download "${RELEASE_TAG}" --pattern SHA256SUMS.txt --dir published/; then
echo "Already published on ${RELEASE_TAG}:"
cat published/SHA256SUMS.txt
else
echo "::warning::${RELEASE_TAG} exists but its SHA256SUMS.txt could not be downloaded - any entry only it covers will be lost"
fi
# Assemble SHA256SUMS.txt from per-platform .sha256 sidecar files
- name: Assemble SHA256SUMS.txt
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
run: bash release-scripts/CI/assemble-release-checksums.sh assets/ published/SHA256SUMS.txt
- name: Generate changelog
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
env:
RELEASE_TYPE: ${{ steps.release-type.outputs.type }}
RELEASE_TAG: ${{ steps.release-type.outputs.tag }}
run: |
if [[ "${RELEASE_TYPE}" == "release" ]]; then
# For release: changelog from previous stable release tag to this tag
PREV_TAG=$(git tag --sort=-version:refname | grep '^Mudlet-' | grep -v -- '-ptb' | grep -v "^${RELEASE_TAG}$" | head -1)
echo "Generating changelog from ${PREV_TAG} to ${RELEASE_TAG}"
if [[ -n "${PREV_TAG}" ]]; then
if ! lua CI/generate-changelog.lua -f md -m release \
--start-commit "${PREV_TAG}" --end-commit "${RELEASE_TAG}" > changelog.md; then
echo "::error::Changelog generation failed for stable release"
exit 1
fi
else
echo "::error::No previous release tag found - cannot generate changelog for stable release"
exit 1
fi
else
# For PTB: short changelog since last PTB (prominent) + full changelog since last stable release (collapsed)
STABLE_TAG=$(git tag --sort=-version:refname | grep '^Mudlet-' | grep -v -- '-ptb' | head -1)
PTB_TAG=$(git tag --sort=-version:refname | grep '^Mudlet-' | grep -- '-ptb' | grep -v "^${RELEASE_TAG}$" | head -1)
# Verify the PTB tag is an ancestor of HEAD
if [[ -n "${PTB_TAG}" ]] && ! git merge-base --is-ancestor "${PTB_TAG}" HEAD; then
echo "::warning::Latest PTB tag ${PTB_TAG} is not an ancestor of HEAD, skipping PTB diff"
PTB_TAG=""
fi
# Generate the full changelog since last stable release
FULL_CHANGELOG=""
if [[ -n "${STABLE_TAG}" ]]; then
echo "Generating full changelog from ${STABLE_TAG} to HEAD"
if ! FULL_CHANGELOG=$(lua CI/generate-changelog.lua -f md -m release \
--start-commit "${STABLE_TAG}" --end-commit HEAD); then
echo "::warning::Full changelog generation failed (from ${STABLE_TAG} to HEAD)"
FULL_CHANGELOG=""
fi
fi
# Generate the incremental changelog since last PTB
PTB_CHANGELOG=""
if [[ -n "${PTB_TAG}" ]]; then
echo "Generating PTB changelog from ${PTB_TAG} to HEAD"
if ! PTB_CHANGELOG=$(lua CI/generate-changelog.lua -f md -m release \
--start-commit "${PTB_TAG}" --end-commit HEAD); then
echo "::warning::PTB changelog generation failed (from ${PTB_TAG} to HEAD)"
PTB_CHANGELOG=""
fi
fi
# Assemble the combined changelog
if [[ -n "${PTB_CHANGELOG}" && -n "${FULL_CHANGELOG}" ]]; then
{
echo "#### Changes since last PTB (${PTB_TAG})"
echo ""
echo "${PTB_CHANGELOG}"
echo ""
echo "<details>"
echo "<summary>Full changelog since last release (${STABLE_TAG})</summary>"
echo ""
echo "${FULL_CHANGELOG}"
echo ""
echo "</details>"
} > changelog.md
elif [[ -n "${FULL_CHANGELOG}" ]]; then
# No previous PTB or first PTB after a release - just show full changelog
echo "${FULL_CHANGELOG}" > changelog.md
else
echo "::warning::Changelog generation failed, using placeholder"
echo "See commit history for changes." > changelog.md
fi
fi
echo "Changelog preview:"
head -20 changelog.md
# Clean up old PTB releases, keeping only the latest 1 (+ the new one = 2 total)
- name: Clean up old PTB releases
if: steps.release-type.outputs.type == 'ptb'
env:
RELEASE_TAG: ${{ steps.release-type.outputs.tag }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Filter by -ptb in tag name to avoid deleting non-PTB prereleases (e.g. release candidates).
# Exclude the current tag so we never delete what we are about to publish.
gh release list --limit 100 --json tagName,isPrerelease,createdAt \
--jq '[.[] | select(.isPrerelease and (.tagName | contains("-ptb")))] | sort_by(.createdAt) | reverse | .[1:] | .[].tagName' | \
while IFS= read -r old_tag; do
if [[ "${old_tag}" == "${RELEASE_TAG}" ]]; then
continue
fi
echo "Cleaning up old PTB release: ${old_tag}"
gh release delete "${old_tag}" --yes --cleanup-tag 2>/dev/null || true
done
# Create the release if it doesn't exist yet, or upload any missing assets.
# Both build workflows trigger this job so it runs twice per commit - this
# avoids the old delete+recreate cycle that spammed notifications.
- name: Create or update GitHub Release
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
env:
RELEASE_TYPE: ${{ steps.release-type.outputs.type }}
RELEASE_TAG: ${{ steps.release-type.outputs.tag }}
RELEASE_TITLE: ${{ steps.release-type.outputs.title }}
TARGET_SHA: ${{ github.event.workflow_run.head_sha }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Everything in assets/ except the per-platform sidecars, which only feed
# SHA256SUMS.txt. Not a list of binary suffixes: a new asset type must not
# be able to reach the release without the checksum gate below seeing it.
mapfile -t BINARIES < <(find assets/ -type f ! -name '*.sha256' ! -name 'SHA256SUMS.txt')
if [[ ${#BINARIES[@]} -eq 0 ]]; then
echo "::error::No release files found to upload"
exit 1
fi
echo "Files to upload:"
printf '%s\n' assets/SHA256SUMS.txt "${BINARIES[@]}"
# Guard against publishing a binary SHA256SUMS.txt does not cover: the
# release ends up holding what is already on it plus what we upload now
: > final-assets.txt
if gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' >> final-assets.txt 2> view-error.txt; then
RELEASE_EXISTS=yes
elif grep -qi 'release not found' view-error.txt; then
RELEASE_EXISTS=no
else
cat view-error.txt
echo "::error::Could not list the assets already published on ${RELEASE_TAG} - refusing to publish without checking checksum coverage"
exit 1
fi
basename -a -- "${BINARIES[@]}" >> final-assets.txt
echo "Assets the release will hold afterwards:"
sort -u final-assets.txt
bash release-scripts/CI/verify-release-checksums.sh assets/SHA256SUMS.txt final-assets.txt assets/
# SHA256SUMS.txt first: it is a superset of the old and new binaries, so if
# the upload dies partway the release is never left holding an uncovered one
if [[ "${RELEASE_EXISTS}" == "yes" ]]; then
echo "Release ${RELEASE_TAG} already exists - uploading any missing assets"
gh release upload "${RELEASE_TAG}" assets/SHA256SUMS.txt --clobber
gh release upload "${RELEASE_TAG}" "${BINARIES[@]}" --clobber
else
ARGS=(
"${RELEASE_TAG}"
--title "${RELEASE_TITLE}"
--notes-file changelog.md
)
if [[ "${RELEASE_TYPE}" == "ptb" ]]; then
ARGS+=(--prerelease --target "${TARGET_SHA}")
fi
gh release create "${ARGS[@]}" assets/SHA256SUMS.txt \
|| gh release upload "${RELEASE_TAG}" assets/SHA256SUMS.txt --clobber
gh release upload "${RELEASE_TAG}" "${BINARIES[@]}" --clobber
fi
# Confirm against the live release, not just the files we meant to upload
- name: Verify published release checksums
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
env:
RELEASE_TAG: ${{ steps.release-type.outputs.tag }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# A freshly uploaded asset is not always immediately readable, so retry
# rather than declaring a good release broken
for attempt in 1 2 3 4 5; do
rm -rf published-final/
mkdir -p published-final/
if gh release download "${RELEASE_TAG}" --pattern SHA256SUMS.txt --dir published-final/ \
&& gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' > published-final/assets.txt; then
break
fi
if [[ "${attempt}" -eq 5 ]]; then
echo "::error::Could not read back the published ${RELEASE_TAG} assets to verify them"
exit 1
fi
echo "Attempt ${attempt} could not read the published release yet - retrying"
sleep 10
done
echo "Published assets:"
cat published-final/assets.txt
bash release-scripts/CI/verify-release-checksums.sh published-final/SHA256SUMS.txt published-final/assets.txt
# Generate and upload Sparkle appcast XML for macOS updates
- name: Add SSH agent for appcast upload
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.UPLOAD_PRIVATEKEY }}
- name: Generate Sparkle appcast XML
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
env:
RELEASE_TYPE: ${{ steps.release-type.outputs.type }}
RELEASE_TAG: ${{ steps.release-type.outputs.tag }}
RELEASE_VERSION: ${{ steps.release-type.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p appcast
# sparkle:version must match CFBundleVersion in the built app
export BUNDLE_VERSION="${RELEASE_TAG#Mudlet-}"
export RELEASE_VERSION
# Convert changelog to HTML for Sparkle's description field
export CHANGELOG_HTML=""
if [[ -f changelog.md ]]; then
CHANGELOG_HTML=$(pandoc changelog.md -f markdown -t html --no-highlight 2>/dev/null || true)
fi
if [[ -z "${CHANGELOG_HTML}" ]]; then
CHANGELOG_HTML="<p>See <a href='https://github.com/Mudlet/Mudlet/releases/tag/${RELEASE_TAG}'>release notes</a> for details.</p>"
fi
CHANNEL="release"
if [[ "${RELEASE_TYPE}" == "ptb" ]]; then
CHANNEL="ptb"
fi
export PUB_DATE=$(date -R)
# Get asset download URLs from the GitHub Release
RELEASE_JSON=$(gh release view "${RELEASE_TAG}" --json assets)
for ARCH in arm64 x86_64; do
DMG_FILE=$(find assets/ -name "*-${ARCH}.dmg" -type f | head -1)
if [[ -z "${DMG_FILE}" ]]; then
echo "No macOS DMG found for ${ARCH} - skipping appcast"
continue
fi
export FILE_SIZE=$(stat --printf="%s" "${DMG_FILE}")
DMG_NAME=$(basename "${DMG_FILE}")
export DOWNLOAD_URL=$(echo "${RELEASE_JSON}" | jq -r --arg name "${DMG_NAME}" '.assets[] | select(.name == $name) | .url')
if [[ -z "${DOWNLOAD_URL}" || "${DOWNLOAD_URL}" == "null" ]]; then
echo "::warning::Could not find download URL for ${DMG_NAME}"
continue
fi
# Use envsubst with quoted heredoc to safely handle special characters in changelog HTML
envsubst '$RELEASE_VERSION $BUNDLE_VERSION $PUB_DATE $DOWNLOAD_URL $FILE_SIZE $CHANGELOG_HTML' \
> "appcast/${CHANNEL}-${ARCH}.xml" << 'APPCAST_EOF'
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>Mudlet Updates</title>
<description>Updates for Mudlet</description>
<language>en</language>
<item>
<title>Mudlet $RELEASE_VERSION</title>
<pubDate>$PUB_DATE</pubDate>
<sparkle:version>$BUNDLE_VERSION</sparkle:version>
<sparkle:shortVersionString>$RELEASE_VERSION</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>11.0</sparkle:minimumSystemVersion>
<description><![CDATA[$CHANGELOG_HTML]]></description>
<enclosure
url="$DOWNLOAD_URL"
length="$FILE_SIZE"
type="application/octet-stream"/>
</item>
</channel>
</rss>
APPCAST_EOF
echo "Generated appcast/${CHANNEL}-${ARCH}.xml (version=${BUNDLE_VERSION}, size=${FILE_SIZE})"
done
echo "Generated appcast files:"
ls -la appcast/ 2>/dev/null || echo "No appcast files generated"
- name: Upload appcast to mudlet.org
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
env:
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
run: |
shopt -s nullglob
APPCAST_FILES=(appcast/*.xml)
shopt -u nullglob
if [[ ${#APPCAST_FILES[@]} -eq 0 ]]; then
echo "::warning::No appcast files to upload"
exit 0
fi
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
mudmachine@make.mudlet.org "mkdir -p \"${DEPLOY_PATH}/appcast\""
for f in "${APPCAST_FILES[@]}"; do
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"$f" "mudmachine@make.mudlet.org:${DEPLOY_PATH}/appcast/"
echo "Uploaded $(basename "$f")"
done
- name: Verify appcast upload
if: steps.release-type.outputs.type == 'release' || steps.release-type.outputs.type == 'ptb'
run: |
shopt -s nullglob
APPCAST_FILES=(appcast/*.xml)
shopt -u nullglob
for f in "${APPCAST_FILES[@]}"; do
basename=$(basename "$f")
url="https://www.mudlet.org/wp-content/files/appcast/${basename}"
if curl --output /dev/null --silent --head --fail "$url"; then
echo "Verified: ${url}"
else
echo "::warning::Appcast not yet accessible at ${url}"
fi
done