Skip to content

fix(embedded): pipe mesh-player output to journald via systemd-cat #35

fix(embedded): pipe mesh-player output to journald via systemd-cat

fix(embedded): pipe mesh-player output to journald via systemd-cat #35

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v0.8.4)'
required: true
type: string
permissions:
contents: write
pages: write
id-token: write
# Only one release workflow per tag at a time — re-pushing a tag cancels the
# previous run so stale builds don't race with the new one.
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CACHE_NAME: mesh-cache
jobs:
# ===========================================================================
# Job 1: Create draft release and validate version
# ===========================================================================
create-release:
name: Create Release
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Get version
id: version
run: |
if [ -n "${{ github.event.inputs.version }}" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
else
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Validate version against Cargo.toml
run: |
TAG_VERSION="${{ steps.version.outputs.version }}"
# Strip 'v' prefix for comparison (v0.8.4 -> 0.8.4)
TAG_SEMVER="${TAG_VERSION#v}"
# Also strip any pre-release suffix for base version check (0.8.4-rc1 -> 0.8.4)
TAG_BASE="${TAG_SEMVER%%-*}"
CARGO_VERSION=$(grep -A2 '^\[workspace\.package\]' Cargo.toml | grep '^version' | sed 's/.*"\(.*\)".*/\1/')
echo "Tag version: $TAG_SEMVER (base: $TAG_BASE)"
echo "Cargo version: $CARGO_VERSION"
if [ "$TAG_BASE" != "$CARGO_VERSION" ]; then
echo "::error::Version mismatch! Tag base '$TAG_BASE' does not match Cargo.toml version '$CARGO_VERSION'"
echo "Update [workspace.package] version in Cargo.toml before tagging."
exit 1
fi
echo "Version check passed."
- name: Extract changelog for this version
id: changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
SEMVER="${VERSION#v}"
# Extract the section between ## [this version] and the next ## [
# Uses awk: start printing at the matching header, stop at the next ## [
NOTES=$(awk -v ver="$SEMVER" '
/^## \[/ {
if (found) exit
if (index($0, "[" ver "]")) found=1
}
found { print }
' CHANGELOG.md)
if [ -z "$NOTES" ]; then
echo "No changelog entry found for $SEMVER"
NOTES="*No changelog entry for this version.*"
fi
# Write to file (multiline strings in GITHUB_OUTPUT need delimiters)
echo "$NOTES" > /tmp/changelog-notes.md
- name: Build release body
run: |
REPO="${{ github.repository }}"
cat > /tmp/release-body.md << EOF
## Installation
### Linux (.deb)
Download the \`.deb\` package for your system:
- **mesh-player** — Performance mode (4-deck stem player)
- **mesh-cue** — Editing mode (waveform editor, analysis, stem separation)
- **mesh-cue-cuda** — Editing mode with NVIDIA GPU acceleration
\`\`\`bash
sudo dpkg -i mesh-player_*.deb mesh-cue_*.deb
# Install dependencies if needed:
sudo apt-get install -f
\`\`\`
**Requirements:** PipeWire (Ubuntu 22.04+, Fedora 34+) or JACK2, glibc 2.35+
### Windows (.zip)
Extract the zip and run the \`.exe\` directly. No installation required.
GPU acceleration via DirectML (any DirectX 12 GPU).
### NixOS / Nix
Run directly (x86_64-linux):
\`\`\`bash
nix run github:${REPO}#mesh-player
nix run github:${REPO}#mesh-cue
\`\`\`
**aarch64-linux** (Orange Pi 5 / embedded): A pre-built binary cache is published to GitHub Pages with every release. Configure the cache and update:
\`\`\`bash
# /etc/nix/nix.conf (or flake nixConfig)
extra-substituters = https://datao1.github.io/Mesh/
extra-trusted-public-keys = mesh-embedded:vLo1l3Abp0Uzcn21wR3oXvmZxZb1Z1rbk+ggTOIGmeQ=
# Update an embedded device
nixos-rebuild switch --flake github:${REPO}#mesh-embedded --no-write-lock-file
\`\`\`
ML models are downloaded automatically on first use.
---
EOF
# Append changelog notes
cat /tmp/changelog-notes.md >> /tmp/release-body.md
- name: Create Release
uses: softprops/action-gh-release@v2
with:
name: Mesh ${{ steps.version.outputs.version }}
tag_name: ${{ steps.version.outputs.version }}
draft: true
prerelease: ${{ contains(steps.version.outputs.version, '-rc') || contains(steps.version.outputs.version, '-beta') || contains(steps.version.outputs.version, '-alpha') }}
body_path: /tmp/release-body.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ===========================================================================
# Job 2: Build Linux .deb packages (CPU)
# ===========================================================================
build-deb:
name: Build .deb (CPU)
needs: create-release
runs-on: ubuntu-latest
steps:
- name: Free disk space
run: |
echo "Before cleanup:"
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
sudo rm -rf /usr/local/graalvm /usr/local/share/chromium /usr/local/.ghcup
echo "After cleanup:"
df -h /
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
- name: Nix store cache
uses: DeterminateSystems/magic-nix-cache-action@v8
with:
use-flakehub: false
- name: Restore native deps cache
uses: actions/cache@v4
with:
path: |
target/deb-build/deps
target/deb-build/onnxruntime-cpu-*
key: deb-native-deps-${{ hashFiles('nix/apps/build-deb.nix') }}
- name: Restore Rust build cache
uses: actions/cache@v4
with:
path: |
target/deb-build/rustup
target/deb-build/cargo
target/deb-build/target
key: deb-rust-${{ hashFiles('Cargo.lock') }}
restore-keys: deb-rust-
- name: Build .deb packages
run: nix run .#build-deb
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: deb-cpu
path: dist/deb/*.deb
if-no-files-found: error
retention-days: 5
# ===========================================================================
# Job 3: Build Linux .deb packages (CUDA)
# ===========================================================================
build-deb-cuda:
name: Build .deb (CUDA)
needs: create-release
runs-on: ubuntu-latest
steps:
- name: Free disk space
run: |
echo "Before cleanup:"
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
sudo rm -rf /usr/local/graalvm /usr/local/share/chromium /usr/local/.ghcup
echo "After cleanup:"
df -h /
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
- name: Nix store cache
uses: DeterminateSystems/magic-nix-cache-action@v8
with:
use-flakehub: false
- name: Restore native deps cache
uses: actions/cache@v4
with:
path: |
target/deb-build/deps
target/deb-build/onnxruntime-gpu-*
key: deb-native-deps-${{ hashFiles('nix/apps/build-deb.nix') }}
- name: Restore Rust build cache
uses: actions/cache@v4
with:
path: |
target/deb-build/rustup
target/deb-build/cargo
target/deb-build/target
key: deb-cuda-rust-${{ hashFiles('Cargo.lock') }}
restore-keys: deb-cuda-rust-
- name: Build CUDA .deb packages
run: nix run .#build-deb-cuda
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: deb-cuda
path: dist/deb/*.deb
if-no-files-found: error
retention-days: 5
# ===========================================================================
# Job 4: Build Windows packages
# ===========================================================================
build-windows:
name: Build Windows
needs: create-release
runs-on: ubuntu-latest
steps:
- name: Free disk space
run: |
echo "Before cleanup:"
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost
sudo rm -rf /usr/local/graalvm /usr/local/share/chromium /usr/local/.ghcup
echo "After cleanup:"
df -h /
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
- name: Nix store cache
uses: DeterminateSystems/magic-nix-cache-action@v8
with:
use-flakehub: false
- name: Restore native deps cache
uses: actions/cache@v4
with:
path: |
target/windows/essentia-win
target/windows/essentia-host
target/windows/essentia-src
target/windows/onnxruntime-directml-*
key: win-native-deps-${{ hashFiles('nix/apps/build-windows.nix') }}
- name: Restore Rust build cache
uses: actions/cache@v4
with:
path: target/windows/x86_64-pc-windows-gnu
key: win-rust-${{ hashFiles('Cargo.lock') }}
restore-keys: win-rust-
- name: Build Windows packages
run: nix run .#build-windows
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows
path: dist/windows/*.zip
if-no-files-found: error
retention-days: 5
# ===========================================================================
# Job 5: Build and sync ML models to permanent 'models' release
# ===========================================================================
build-models:
name: Sync Models
needs: create-release
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
- name: Nix store cache
uses: DeterminateSystems/magic-nix-cache-action@v8
with:
use-flakehub: false
- name: Restore model cache
id: model-cache
uses: actions/cache@v4
with:
path: models/
key: models-${{ hashFiles('nix/apps/convert-beat-model.nix', 'nix/apps/convert-ml-model.nix', 'nix/apps/convert-model.nix') }}
- name: Convert Beat This! model
if: steps.model-cache.outputs.cache-hit != 'true'
run: |
mkdir -p models
# Remove existing to force reconversion
rm -f models/beat_this_small.onnx
nix run .#convert-beat-model
- name: Convert genre classification head
if: steps.model-cache.outputs.cache-hit != 'true'
run: |
rm -f models/genre_discogs400-discogs-effnet-1.onnx
nix run .#convert-ml-model
- name: Convert Demucs models
if: steps.model-cache.outputs.cache-hit != 'true'
run: |
rm -f models/htdemucs.onnx models/htdemucs.onnx.data
nix run .#convert-model -- htdemucs ./models
rm -f models/htdemucs_ft.onnx models/htdemucs_ft.onnx.data
nix run .#convert-model -- htdemucs_ft ./models
- name: Download pre-built Essentia models
if: steps.model-cache.outputs.cache-hit != 'true'
run: |
# EffNet embedding model (ONNX published directly by Essentia)
curl --fail --location --progress-bar \
-o models/discogs-effnet-bsdynamic-1.onnx \
"https://essentia.upf.edu/models/music-style-classification/discogs-effnet/discogs-effnet-bsdynamic-1.onnx"
# Jamendo mood/theme classification head (ONNX published directly by Essentia)
curl --fail --location --progress-bar \
-o models/mtg_jamendo_moodtheme-discogs-effnet-1.onnx \
"https://essentia.upf.edu/models/classification-heads/mtg_jamendo_moodtheme/mtg_jamendo_moodtheme-discogs-effnet-1.onnx"
- name: Upload models to permanent release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Models to upload:"
ls -lh models/*.onnx* 2>/dev/null || true
# Ensure the 'models' release exists
if ! gh release view models &>/dev/null; then
gh release create models \
--title "ML Models" \
--notes "Pre-built ONNX models for stem separation, beat detection, and audio classification. Downloaded automatically by mesh-cue on first use."
fi
# Upload all model files (--clobber overwrites existing)
gh release upload models models/*.onnx* --clobber
echo "Models synced to 'models' release."
# ===========================================================================
# Job 6: Build aarch64 mesh-player and publish Nix binary cache
# ===========================================================================
# Runs on every version tag so embedded devices can update via nixos-rebuild.
# The binary cache is served as static files from GitHub Pages at
# https://datao1.github.io/Mesh/ — the Orange Pi fetches pre-built packages
# from here instead of compiling from source.
build-aarch64:
name: Build aarch64 + Cache
needs: create-release
runs-on: ubuntu-24.04-arm # Native aarch64 runner (free for public repos)
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
extra-substituters = https://datao1.github.io/Mesh/
extra-trusted-public-keys = mesh-embedded:vLo1l3Abp0Uzcn21wR3oXvmZxZb1Z1rbk+ggTOIGmeQ=
- name: Nix store cache
uses: DeterminateSystems/magic-nix-cache-action@v8
with:
use-flakehub: false
- name: Set up signing key
run: |
echo "${{ secrets.NIX_CACHE_PRIV_KEY }}" > /tmp/cache-priv-key.pem
# Validate key format: must be "name:base64key"
if ! grep -qE '^[a-zA-Z0-9_.-]+:' /tmp/cache-priv-key.pem; then
echo "::error::NIX_CACHE_PRIV_KEY must be in format 'keyname:base64key'"
exit 1
fi
echo "Signing key loaded ($(wc -c < /tmp/cache-priv-key.pem) bytes)"
- name: Build mesh-player
run: |
echo "Building mesh-player for aarch64-linux..."
nix build -L .#packages.aarch64-linux.mesh-player
- name: Create binary cache
run: |
CACHE_DIR="$(pwd)/${{ env.CACHE_NAME }}"
CACHE_STORE="file://${CACHE_DIR}"
mkdir -p "${CACHE_DIR}"
echo "Copying store paths to binary cache..."
nix copy --to "${CACHE_STORE}" ./result
echo "Signing all paths in cache..."
nix store sign \
--store "${CACHE_STORE}" \
--key-file /tmp/cache-priv-key.pem \
--all
echo ""
echo "Cache contents:"
ls -lh "${CACHE_DIR}"/
echo ""
du -sh "${CACHE_DIR}"
echo ""
echo "narinfo count: $(ls "${CACHE_DIR}"/*.narinfo 2>/dev/null | wc -l)"
- name: Verify cache signatures
run: |
CACHE_DIR="$(pwd)/${{ env.CACHE_NAME }}"
# Extract the store path hash (base32, first component after /nix/store/)
STORE_PATH=$(readlink ./result)
STORE_HASH=$(basename "$STORE_PATH" | cut -d'-' -f1)
echo "Checking narinfo for mesh-player (${STORE_HASH})..."
NARINFO="${CACHE_DIR}/${STORE_HASH}.narinfo"
if [ ! -f "$NARINFO" ]; then
echo "::error::narinfo not found: ${NARINFO}"
echo "Available narinfos:"
ls "${CACHE_DIR}"/*.narinfo 2>/dev/null | head -5
exit 1
fi
if grep -q "^Sig:" "$NARINFO"; then
echo "Signature found:"
grep "^Sig:" "$NARINFO"
else
echo "::error::mesh-player narinfo has no signature! Check NIX_CACHE_PRIV_KEY secret."
echo "narinfo contents:"
cat "$NARINFO"
exit 1
fi
- name: Generate index.html
run: |
cd ./${{ env.CACHE_NAME }}
VERSION="${{ needs.create-release.outputs.version }}"
NARINFO_COUNT=$(ls *.narinfo 2>/dev/null | wc -l)
CACHE_SIZE=$(du -sh . | cut -f1)
NAR_COUNT=$(ls nar/*.nar.xz 2>/dev/null | wc -l)
cat > index.html << 'HEADER'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Mesh — Nix Binary Cache</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; color: #e0e0e0; background: #1a1a2e; }
h1 { color: #fff; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin: 1.5rem 0; }
.stat { background: #16213e; padding: 1rem; border-radius: 8px; text-align: center; }
.stat-value { font-size: 1.5rem; font-weight: bold; color: #0ea5e9; }
.stat-label { font-size: 0.85rem; color: #94a3b8; margin-top: 0.25rem; }
code { background: #16213e; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.9rem; }
pre { background: #16213e; padding: 1rem; border-radius: 8px; overflow-x: auto; }
a { color: #0ea5e9; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #2a2a4a; }
th { color: #94a3b8; font-weight: normal; font-size: 0.85rem; }
td a { text-decoration: none; }
.version-banner { margin: 1.5rem 0; padding: 1.25rem; background: linear-gradient(135deg, #16213e 0%, #1a1a3e 100%); border: 1px solid #0ea5e9; border-radius: 10px; display: flex; align-items: center; justify-content: space-between; }
.version-banner .ver { font-size: 2rem; font-weight: bold; color: #fff; }
.version-banner .ver a { color: #fff; text-decoration: none; }
.version-banner .ver a:hover { color: #0ea5e9; }
.version-banner .release-link { background: #0ea5e9; color: #1a1a2e; padding: 0.5rem 1.25rem; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 0.9rem; }
.version-banner .release-link:hover { background: #38bdf8; }
.footer { margin-top: 2rem; color: #64748b; font-size: 0.85rem; }
</style>
</head>
<body>
<h1>Mesh — Nix Binary Cache</h1>
<p>Pre-built aarch64-linux packages for the
<a href="https://github.com/dataO1/Mesh">Mesh DJ Player</a>
embedded deployment (Orange Pi 5).</p>
HEADER
REPO="${{ github.repository }}"
RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}"
cat >> index.html << EOF
<div class="version-banner">
<div class="ver"><a href="${RELEASE_URL}">${VERSION}</a></div>
<a class="release-link" href="${RELEASE_URL}">View Release &rarr;</a>
</div>
<div class="stats">
<div class="stat"><div class="stat-value">${NARINFO_COUNT}</div><div class="stat-label">packages</div></div>
<div class="stat"><div class="stat-value">${CACHE_SIZE}</div><div class="stat-label">total size</div></div>
</div>
<h2>Usage</h2>
<p>Add to <code>/etc/nix/nix.conf</code> or flake config:</p>
<pre>extra-substituters = https://datao1.github.io/Mesh/
extra-trusted-public-keys = mesh-embedded:vLo1l3Abp0Uzcn21wR3oXvmZxZb1Z1rbk+ggTOIGmeQ=</pre>
<p>Update the embedded device:</p>
<pre>nixos-rebuild switch --flake github:dataO1/Mesh/${VERSION}#mesh-embedded --no-write-lock-file</pre>
<h2>Packages</h2>
<table>
<tr><th>Package</th><th>Size</th></tr>
EOF
for f in *.narinfo; do
STORE_PATH=$(grep "^StorePath:" "$f" | sed 's/StorePath: \/nix\/store\///')
NAR_SIZE=$(grep "^NarSize:" "$f" | awk '{printf "%.1f MB", $2/1048576}')
echo " <tr><td><a href=\"$f\">$STORE_PATH</a></td><td>$NAR_SIZE</td></tr>" >> index.html
done
cat >> index.html << EOF
</table>
<p class="footer">Last updated: $(date -u '+%Y-%m-%d %H:%M UTC') · <a href="${RELEASE_URL}">${VERSION}</a></p>
</body>
</html>
EOF
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./${{ env.CACHE_NAME }}
keep_files: true
commit_message: "cache: ${{ needs.create-release.outputs.version }} aarch64-linux"
- name: Clean up signing key
if: always()
run: rm -f /tmp/cache-priv-key.pem
# ===========================================================================
# Job 7: Collect artifacts and publish release
# ===========================================================================
publish-release:
name: Publish Release
needs: [create-release, build-deb, build-deb-cuda, build-windows, build-models, build-aarch64]
runs-on: ubuntu-latest
if: always() && needs.create-release.result == 'success'
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts/
merge-multiple: true
- name: List artifacts
run: |
echo "Downloaded artifacts:"
find artifacts/ -type f 2>/dev/null | sort || echo "No artifacts found"
- name: Upload artifacts to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ needs.create-release.outputs.version }}"
echo "Uploading to release: $VERSION"
# Upload all .deb and .zip files found
shopt -s nullglob
FILES=(artifacts/*.deb artifacts/*.zip)
if [ ${#FILES[@]} -gt 0 ]; then
gh release upload "$VERSION" "${FILES[@]}" --clobber --repo "${{ github.repository }}"
echo "Uploaded ${#FILES[@]} file(s)"
else
echo "::warning::No artifacts to upload"
fi
- name: Publish release if all builds succeeded
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ needs.create-release.outputs.version }}"
DEB_OK="${{ needs.build-deb.result }}"
CUDA_OK="${{ needs.build-deb-cuda.result }}"
WIN_OK="${{ needs.build-windows.result }}"
MODELS_OK="${{ needs.build-models.result }}"
if [ "$DEB_OK" = "success" ] && [ "$CUDA_OK" = "success" ] && [ "$WIN_OK" = "success" ]; then
echo "All app builds succeeded — publishing release"
gh release edit "$VERSION" --draft=false --repo "${{ github.repository }}"
else
echo "::warning::Some builds failed — leaving release as draft"
echo " build-deb: $DEB_OK"
echo " build-deb-cuda: $CUDA_OK"
echo " build-windows: $WIN_OK"
echo " build-models: $MODELS_OK"
fi
- name: Summary
if: always()
run: |
VERSION="${{ needs.create-release.outputs.version }}"
echo "## Release $VERSION" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Create Release | ${{ needs.create-release.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Build .deb (CPU) | ${{ needs.build-deb.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Build .deb (CUDA) | ${{ needs.build-deb-cuda.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Build Windows | ${{ needs.build-windows.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Build aarch64 + Cache | ${{ needs.build-aarch64.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Sync Models | ${{ needs.build-models.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
DEB_OK="${{ needs.build-deb.result }}"
CUDA_OK="${{ needs.build-deb-cuda.result }}"
WIN_OK="${{ needs.build-windows.result }}"
if [ "$DEB_OK" = "success" ] && [ "$CUDA_OK" = "success" ] && [ "$WIN_OK" = "success" ]; then
echo "**Release published.**" >> $GITHUB_STEP_SUMMARY
else
echo "**Release left as draft** — some builds failed." >> $GITHUB_STEP_SUMMARY
fi