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 "
" echo "Full changelog since last release (${STABLE_TAG})" echo "" echo "${FULL_CHANGELOG}" echo "" echo "
" } > 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="

See release notes for details.

" 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' Mudlet Updates Updates for Mudlet en Mudlet $RELEASE_VERSION $PUB_DATE $BUNDLE_VERSION $RELEASE_VERSION 11.0 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