Skip to content

feat(v1.2.14): network resilience + Soniox 2-step upload + sliding wi… #32

feat(v1.2.14): network resilience + Soniox 2-step upload + sliding wi…

feat(v1.2.14): network resilience + Soniox 2-step upload + sliding wi… #32

name: Release Manifest
# Generates latest.json for tauri-plugin-updater after all three platform
# builds (build.yml, build-linux.yml, build-macos.yml) have uploaded their
# signed artifacts to the release. Runs on the same `v*` tag push but with
# a longer wait window — polls the release every 30s for up to 45 min until
# all expected .sig files are present, then downloads, reads signature bodies,
# constructs latest.json, and uploads it.
#
# Without this file at <release>/latest.json, the Tauri updater endpoint
# returns 404 and the client never sees an update available.
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: 'Tag to build manifest for (e.g. v1.2.0). Defaults to latest release.'
required: false
permissions:
contents: write
jobs:
build-manifest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Resolve target tag
id: resolve
run: |
if [ -n "${{ inputs.tag }}" ]; then
TAG="${{ inputs.tag }}"
elif [[ "$GITHUB_REF" == refs/tags/v* ]]; then
TAG="$GITHUB_REF_NAME"
else
TAG=$(gh release view --json tagName -q .tagName)
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Target: $TAG (version $VERSION)"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Wait for signed artifacts
# Each platform's build workflow uploads its artifact + .sig
# independently. We poll the release until ALL platforms have either
# (a) a .sig file present, or (b) it's been long enough that we
# consider that platform "unsignable on this release" (e.g. secret
# not configured) and skip it. The manifest is still generated for
# whatever platforms did sign — partial coverage is better than none.
id: wait
run: |
TAG="${{ steps.resolve.outputs.tag }}"
VERSION="${{ steps.resolve.outputs.version }}"
# Expected updater artifacts. Note: deb is NOT an updater target —
# Tauri's Linux updater only supports AppImage.
WIN_SIG="Scribble_${VERSION}_x64-setup.exe.sig"
MAC_SIG="Scribble_${VERSION}_aarch64.app.tar.gz.sig"
LIN_SIG="Scribble_${VERSION}_amd64.AppImage.sig"
MAX_WAIT=2700 # 45 minutes — generous for Windows/macOS PyInstaller
ELAPSED=0
COUNT=0
while [ $ELAPSED -lt $MAX_WAIT ]; do
# `gh release view` fails (exit 1) when the release doesn't exist
# yet — race condition: this workflow runs at the same time as the
# 3 builds, and the first build hasn't reached its "Ensure release
# exists" step yet. Swallow the error and treat as "no assets yet".
ASSETS=$(gh release view "$TAG" --json assets -q '.assets[].name' 2>/dev/null || true)
HAS_WIN=$(echo "$ASSETS" | grep -Fx "$WIN_SIG" || true)
HAS_MAC=$(echo "$ASSETS" | grep -Fx "$MAC_SIG" || true)
HAS_LIN=$(echo "$ASSETS" | grep -Fx "$LIN_SIG" || true)
COUNT=0
[ -n "$HAS_WIN" ] && COUNT=$((COUNT + 1))
[ -n "$HAS_MAC" ] && COUNT=$((COUNT + 1))
[ -n "$HAS_LIN" ] && COUNT=$((COUNT + 1))
echo "[$ELAPSED s] signatures present: win=$([ -n "$HAS_WIN" ] && echo y || echo n) mac=$([ -n "$HAS_MAC" ] && echo y || echo n) lin=$([ -n "$HAS_LIN" ] && echo y || echo n)"
if [ $COUNT -eq 3 ]; then
echo "✅ All 3 platforms signed"
break
fi
sleep 30
ELAPSED=$((ELAPSED + 30))
done
if [ $COUNT -eq 0 ]; then
echo "❌ No platform signed within $MAX_WAIT s — aborting (check TAURI_SIGNING_PRIVATE_KEY secret)"
exit 1
fi
if [ $COUNT -lt 3 ]; then
echo "⚠️ Only $COUNT/3 platforms signed — generating partial manifest"
fi
echo "has_win=$([ -n "$HAS_WIN" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "has_mac=$([ -n "$HAS_MAC" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "has_lin=$([ -n "$HAS_LIN" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Download signatures + build manifest
run: |
TAG="${{ steps.resolve.outputs.tag }}"
VERSION="${{ steps.resolve.outputs.version }}"
BASE_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}"
mkdir -p sigs && cd sigs
# Fetch release body (notes) to embed — markdown is fine,
# client-side UpdateChecker renders it.
NOTES=$(gh release view "$TAG" --json body -q .body)
# Strip CRLF and trim trailing whitespace
NOTES=$(echo "$NOTES" | tr -d '\r' | sed -e 's/[[:space:]]*$//')
PUB_DATE=$(gh release view "$TAG" --json publishedAt -q .publishedAt)
if [ -z "$PUB_DATE" ] || [ "$PUB_DATE" = "null" ]; then
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
fi
# Pass values through env vars instead of shell substitution into
# the Python source — previous version interpolated GHA outputs
# directly, which expanded to `has_win = true` (Python NameError,
# since Python uses True). Env vars also dodge quoting hazards
# in NOTES (release body markdown can contain quotes / backticks).
export MANIFEST_TAG="$TAG"
export MANIFEST_VERSION="$VERSION"
export MANIFEST_BASE_URL="$BASE_URL"
export MANIFEST_NOTES="$NOTES"
export MANIFEST_PUB_DATE="$PUB_DATE"
python3 - <<'PYEOF'
import json, os, subprocess
tag = os.environ["MANIFEST_TAG"]
version = os.environ["MANIFEST_VERSION"]
base_url = os.environ["MANIFEST_BASE_URL"]
notes = os.environ.get("MANIFEST_NOTES", "")
pub_date = os.environ["MANIFEST_PUB_DATE"]
def try_fetch_sig(name):
"""Download a .sig from the release; return contents, or None
if it doesn't exist (allows partial manifests)."""
try:
subprocess.run(
["gh", "release", "download", tag, "-p", name, "-O", name, "--clobber"],
check=True, capture_output=True,
)
except subprocess.CalledProcessError:
return None
with open(name, "r") as f:
return f.read().strip()
platforms = {}
win_sig = try_fetch_sig(f"Scribble_{version}_x64-setup.exe.sig")
if win_sig:
platforms["windows-x86_64"] = {
"signature": win_sig,
"url": f"{base_url}/Scribble_{version}_x64-setup.exe",
}
mac_sig = try_fetch_sig(f"Scribble_{version}_aarch64.app.tar.gz.sig")
if mac_sig:
platforms["darwin-aarch64"] = {
"signature": mac_sig,
"url": f"{base_url}/Scribble_{version}_aarch64.app.tar.gz",
}
lin_sig = try_fetch_sig(f"Scribble_{version}_amd64.AppImage.sig")
if lin_sig:
platforms["linux-x86_64"] = {
"signature": lin_sig,
"url": f"{base_url}/Scribble_{version}_amd64.AppImage",
}
if not platforms:
raise SystemExit("❌ No .sig file could be downloaded — aborting")
manifest = {
"version": tag,
"notes": notes,
"pub_date": pub_date,
"platforms": platforms,
}
with open("../latest.json", "w") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
print("✅ Manifest written. Platforms:", list(platforms.keys()))
PYEOF
cd ..
cat latest.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload manifest to release
run: |
gh release upload "${{ steps.resolve.outputs.tag }}" latest.json --clobber
echo "✅ latest.json uploaded to ${{ steps.resolve.outputs.tag }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}