chore: prepare version 0.0.86 and patch frontend dependency advisories #36
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release (macOS, Linux) | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| concurrency: | |
| group: release-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: write | |
| jobs: | |
| ensure-release: | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - name: Ensure release exists and stays prerelease while assets publish | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| TAG: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| if gh release view "${TAG}" --repo "${REPO}" >/dev/null 2>&1; then | |
| RELEASE_ID="$(gh release view "${TAG}" --repo "${REPO}" --json databaseId --jq '.databaseId')" | |
| gh api --method PATCH "/repos/${REPO}/releases/${RELEASE_ID}" \ | |
| -F prerelease=true \ | |
| -F make_latest=false \ | |
| --silent | |
| else | |
| gh release create "${TAG}" \ | |
| --repo "${REPO}" \ | |
| --title "${TAG}" \ | |
| --generate-notes \ | |
| --prerelease \ | |
| --verify-tag | |
| fi | |
| build-and-upload: | |
| needs: ensure-release | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: macos-latest | |
| bundles: dmg,app | |
| target: universal-apple-darwin | |
| meta_os: macos | |
| - os: ubuntu-24.04 | |
| bundles: appimage,deb | |
| target: "" | |
| meta_os: linux | |
| runs-on: ${{ matrix.os }} | |
| env: | |
| HAS_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} | |
| steps: | |
| - name: Checkout tag | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.ref_name }} | |
| - name: Setup Node | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Setup Rust | |
| uses: dtolnay/rust-toolchain@stable | |
| - name: Install macOS targets (universal build) | |
| if: runner.os == 'macOS' | |
| run: | | |
| rustup target add aarch64-apple-darwin x86_64-apple-darwin | |
| - name: Build universal smoke/soak helpers for macOS bundle | |
| if: runner.os == 'macOS' && matrix.target == 'universal-apple-darwin' | |
| run: | | |
| set -euo pipefail | |
| for target in x86_64-apple-darwin aarch64-apple-darwin; do | |
| cargo build \ | |
| --manifest-path src-tauri/Cargo.toml \ | |
| --release \ | |
| --target "$target" \ | |
| --bin smoke \ | |
| --bin soak | |
| done | |
| mkdir -p src-tauri/target/universal-apple-darwin/release | |
| for bin in smoke soak; do | |
| lipo -create \ | |
| "src-tauri/target/x86_64-apple-darwin/release/${bin}" \ | |
| "src-tauri/target/aarch64-apple-darwin/release/${bin}" \ | |
| -output "src-tauri/target/universal-apple-darwin/release/${bin}" | |
| chmod +x "src-tauri/target/universal-apple-darwin/release/${bin}" | |
| done | |
| - name: Rust cache | |
| uses: Swatinem/rust-cache@v2 | |
| with: | |
| workspaces: | | |
| src-tauri -> target | |
| - name: Install Linux dependencies | |
| if: runner.os == 'Linux' | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| libwebkit2gtk-4.1-dev \ | |
| libgtk-3-dev \ | |
| libayatana-appindicator3-dev \ | |
| librsvg2-dev \ | |
| libssl-dev \ | |
| pkg-config \ | |
| patchelf | |
| - name: Install JS dependencies | |
| run: npm ci | |
| - name: Validate updater pubkey is configured | |
| if: ${{ env.HAS_SIGNING_KEY == 'true' }} | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' | |
| import base64 | |
| import binascii | |
| import json | |
| cfg = json.load(open("src-tauri/tauri.conf.json", "r", encoding="utf-8")) | |
| pubkey = (cfg.get("plugins", {}) or {}).get("updater", {}).get("pubkey", "") or "" | |
| if pubkey.strip() == "REPLACE_WITH_TAURI_UPDATER_PUBLIC_KEY": | |
| raise SystemExit( | |
| "Updater pubkey is still a placeholder. Update src-tauri/tauri.conf.json before enabling signed updater builds." | |
| ) | |
| try: | |
| base64.b64decode(pubkey.strip(), validate=True) | |
| except (binascii.Error, ValueError): | |
| raise SystemExit( | |
| "Updater pubkey is not valid base64. Update src-tauri/tauri.conf.json before enabling signed updater builds." | |
| ) | |
| PY | |
| - name: Build Tauri bundles (signed updater) | |
| if: ${{ env.HAS_SIGNING_KEY == 'true' }} | |
| env: | |
| TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} | |
| TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| run_build() { | |
| if [ -n "${{ matrix.target }}" ]; then | |
| npm run tauri build -- --config src-tauri/tauri.updater.conf.json --target "${{ matrix.target }}" --bundles "${{ matrix.bundles }}" | |
| else | |
| npm run tauri build -- --config src-tauri/tauri.updater.conf.json --bundles "${{ matrix.bundles }}" | |
| fi | |
| } | |
| for attempt in 1 2 3; do | |
| if run_build; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "Tauri build failed after 3 attempts." | |
| exit 1 | |
| fi | |
| sleep_seconds=$((attempt * 20)) | |
| echo "Tauri build failed, retrying in ${sleep_seconds}s (attempt ${attempt}/3)" | |
| sleep "$sleep_seconds" | |
| done | |
| - name: Build Tauri bundles (unsigned) | |
| if: ${{ env.HAS_SIGNING_KEY != 'true' }} | |
| run: | | |
| set -euo pipefail | |
| run_build() { | |
| if [ -n "${{ matrix.target }}" ]; then | |
| npm run tauri build -- --target "${{ matrix.target }}" --bundles "${{ matrix.bundles }}" | |
| else | |
| npm run tauri build -- --bundles "${{ matrix.bundles }}" | |
| fi | |
| } | |
| for attempt in 1 2 3; do | |
| if run_build; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "Tauri build failed after 3 attempts." | |
| exit 1 | |
| fi | |
| sleep_seconds=$((attempt * 20)) | |
| echo "Tauri build failed, retrying in ${sleep_seconds}s (attempt ${attempt}/3)" | |
| sleep "$sleep_seconds" | |
| done | |
| - name: Zip macOS .app bundle(s) | |
| if: runner.os == 'macOS' | |
| run: | | |
| set -euo pipefail | |
| for app in src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app; do | |
| if [ -d "$app" ]; then | |
| echo "Zipping: $app" | |
| ditto -c -k --sequesterRsrc --keepParent "$app" "${app}.zip" | |
| fi | |
| done | |
| - name: Verify macOS artifacts before release upload | |
| if: runner.os == 'macOS' | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| files=( | |
| src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg | |
| src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.zip | |
| ) | |
| if [ "${#files[@]}" -eq 0 ]; then | |
| echo "No macOS release assets found. Check bundle output:" | |
| find "src-tauri/target/${{ matrix.target }}/release/bundle" -maxdepth 3 -type f || true | |
| exit 1 | |
| fi | |
| printf '%s\0' "${files[@]}" > release_upload_files.m0 | |
| - name: Collect updater metadata | |
| if: ${{ env.HAS_SIGNING_KEY == 'true' }} | |
| run: | | |
| set -euo pipefail | |
| META_OS="${{ matrix.meta_os }}" | |
| BUNDLE_DIR="src-tauri/target/release/bundle" | |
| if [ -n "${{ matrix.target }}" ]; then | |
| BUNDLE_DIR="src-tauri/target/${{ matrix.target }}/release/bundle" | |
| fi | |
| export META_OS BUNDLE_DIR | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| import pathlib | |
| import shutil | |
| meta_os = os.environ.get("META_OS", "").strip() | |
| bundle_dir = pathlib.Path(os.environ["BUNDLE_DIR"]).resolve() | |
| if not bundle_dir.exists(): | |
| raise SystemExit(f"Bundle dir not found: {bundle_dir}") | |
| required_installers = { | |
| "macos": ["app"], | |
| "linux": ["appimage", "deb"], | |
| }.get(meta_os, []) | |
| if not required_installers: | |
| raise SystemExit(f"Unsupported META_OS: {meta_os}") | |
| def detect_installer(asset_name_lower: str) -> str | None: | |
| if asset_name_lower.endswith(".app.tar.gz"): | |
| return "app" | |
| if asset_name_lower.endswith(".appimage") or asset_name_lower.endswith(".appimage.tar.gz"): | |
| return "appimage" | |
| if asset_name_lower.endswith(".deb"): | |
| return "deb" | |
| if asset_name_lower.endswith(".rpm"): | |
| return "rpm" | |
| return None | |
| items_by_installer: dict[str, list[dict]] = {} | |
| for sig in bundle_dir.rglob("*.sig"): | |
| asset = sig.with_suffix("") | |
| if not asset.exists(): | |
| continue | |
| name = asset.name | |
| low_name = name.lower() | |
| low_path = str(asset).lower() | |
| installer = detect_installer(low_name) | |
| if not installer: | |
| continue | |
| items_by_installer.setdefault(installer, []).append({ | |
| "asset_path": str(asset), | |
| "sig_path": str(sig), | |
| "asset_name": name, | |
| "sig_name": sig.name, | |
| "signature": sig.read_text(encoding="utf-8").strip(), | |
| "path_hint": low_path, | |
| "installer": installer, | |
| }) | |
| missing = [name for name in required_installers if name not in items_by_installer] | |
| if missing: | |
| raise SystemExit(f"Missing updater artifacts for installer(s): {', '.join(missing)}") | |
| def score(item): | |
| p = item["path_hint"] | |
| n = item["asset_name"].lower() | |
| installer = item["installer"] | |
| s = 0 | |
| if meta_os == "macos": | |
| if "/macos/" in p: | |
| s += 20 | |
| if ".app.tar.gz" in n: | |
| s += 10 | |
| if "universal" in n: | |
| s += 5 | |
| elif meta_os == "linux": | |
| if "/appimage/" in p: | |
| s += 20 | |
| if "/deb/" in p: | |
| s += 10 | |
| if ".appimage" in n: | |
| s += 10 | |
| if installer == "appimage" and n.endswith(".tar.gz"): | |
| s += 5 | |
| if installer == "deb" and n.endswith(".deb"): | |
| s += 5 | |
| return s | |
| chosen_by_installer: dict[str, dict] = {} | |
| for installer, items in items_by_installer.items(): | |
| items.sort(key=lambda it: (-score(it), it["asset_name"])) | |
| chosen_by_installer[installer] = items[0] | |
| installers_out: dict[str, dict] = {} | |
| out_dir = pathlib.Path("updater_assets") | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| for installer in required_installers: | |
| chosen = chosen_by_installer[installer] | |
| # Avoid duplicate asset uploads: Linux job uploads .deb and .AppImage separately. | |
| copy_asset = True | |
| asset_path_hint = chosen["path_hint"] | |
| asset_name_lower = chosen["asset_name"].lower() | |
| if meta_os == "linux": | |
| if installer == "deb" and "/bundle/deb/" in asset_path_hint and asset_name_lower.endswith(".deb"): | |
| copy_asset = False | |
| if installer == "appimage" and "/bundle/appimage/" in asset_path_hint and asset_name_lower.endswith(".appimage"): | |
| copy_asset = False | |
| if copy_asset: | |
| shutil.copy2(chosen["asset_path"], out_dir / chosen["asset_name"]) | |
| shutil.copy2(chosen["sig_path"], out_dir / chosen["sig_name"]) | |
| installers_out[installer] = { | |
| "asset_name": chosen["asset_name"], | |
| "sig_name": chosen["sig_name"], | |
| "signature": chosen["signature"], | |
| } | |
| out = {"os": meta_os, "installers": installers_out} | |
| out_path = pathlib.Path(f"updater-meta-{meta_os}.json") | |
| out_path.write_text(json.dumps(out, indent=2), encoding="utf-8") | |
| print(f"Wrote {out_path}") | |
| PY | |
| - name: Verify updater assets before upload | |
| if: ${{ env.HAS_SIGNING_KEY == 'true' }} | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| assets=(updater_assets/*) | |
| if [ "${#assets[@]}" -eq 0 ]; then | |
| echo "No updater assets prepared. Check collect step:" | |
| ls -la updater_assets || true | |
| exit 1 | |
| fi | |
| printf '%s\0' "${assets[@]}" > updater_upload_files.m0 | |
| - name: Upload updater metadata artifact | |
| if: ${{ env.HAS_SIGNING_KEY == 'true' }} | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: updater-meta-${{ matrix.meta_os }} | |
| if-no-files-found: error | |
| path: updater-meta-${{ matrix.meta_os }}.json | |
| - name: Upload updater assets to GitHub Release | |
| if: ${{ env.HAS_SIGNING_KEY == 'true' }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| files=() | |
| while IFS= read -r -d '' path; do | |
| files+=("$path") | |
| done < updater_upload_files.m0 | |
| for attempt in 1 2 3; do | |
| if gh release upload "${{ github.ref_name }}" "${files[@]}" --clobber; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "Failed to upload updater assets after 3 attempts." | |
| exit 1 | |
| fi | |
| sleep_seconds=$((attempt * 10)) | |
| echo "Upload failed, retrying in ${sleep_seconds}s (attempt ${attempt}/3)" | |
| sleep "$sleep_seconds" | |
| done | |
| - name: Upload macOS assets to GitHub Release | |
| if: runner.os == 'macOS' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| files=() | |
| while IFS= read -r -d '' path; do | |
| files+=("$path") | |
| done < release_upload_files.m0 | |
| for attempt in 1 2 3; do | |
| if gh release upload "${{ github.ref_name }}" "${files[@]}" --clobber; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "Failed to upload macOS assets after 3 attempts." | |
| exit 1 | |
| fi | |
| sleep_seconds=$((attempt * 10)) | |
| echo "Upload failed, retrying in ${sleep_seconds}s (attempt ${attempt}/3)" | |
| sleep "$sleep_seconds" | |
| done | |
| - name: Upload Linux assets to GitHub Release | |
| if: runner.os == 'Linux' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob | |
| files=( | |
| src-tauri/target/release/bundle/appimage/*.AppImage | |
| src-tauri/target/release/bundle/deb/*.deb | |
| ) | |
| if [ "${#files[@]}" -eq 0 ]; then | |
| echo "No Linux release assets found." | |
| find "src-tauri/target/release/bundle" -maxdepth 3 -type f || true | |
| exit 1 | |
| fi | |
| for attempt in 1 2 3; do | |
| if gh release upload "${{ github.ref_name }}" "${files[@]}" --clobber; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "Failed to upload Linux assets after 3 attempts." | |
| exit 1 | |
| fi | |
| sleep_seconds=$((attempt * 10)) | |
| echo "Upload failed, retrying in ${sleep_seconds}s (attempt ${attempt}/3)" | |
| sleep "$sleep_seconds" | |
| done | |
| publish-latest-json: | |
| needs: build-and-upload | |
| runs-on: ubuntu-24.04 | |
| env: | |
| HAS_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} | |
| steps: | |
| - name: Download updater metadata artifacts | |
| if: ${{ env.HAS_SIGNING_KEY == 'true' }} | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: updater-meta | |
| - name: Build latest.json | |
| if: ${{ env.HAS_SIGNING_KEY == 'true' }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| TAG: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| gh api "/repos/${REPO}/releases/tags/${TAG}" > release-meta.json | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| import pathlib | |
| repo = os.environ["REPO"] | |
| tag = os.environ["TAG"] | |
| with open("release-meta.json", "r", encoding="utf-8") as fh: | |
| release = json.load(fh) | |
| body = release.get("body", "") or "" | |
| pub_date = release.get("published_at", "") or release.get("publishedAt", "") or "" | |
| version = tag[1:] if tag.startswith("v") else tag | |
| assets = release.get("assets") or [] | |
| mac_path = pathlib.Path("updater-meta") / "updater-meta-macos" / "updater-meta-macos.json" | |
| linux_path = pathlib.Path("updater-meta") / "updater-meta-linux" / "updater-meta-linux.json" | |
| mac = json.loads(mac_path.read_text(encoding="utf-8")) | |
| linux = json.loads(linux_path.read_text(encoding="utf-8")) | |
| mac_app = (mac.get("installers") or {}).get("app") or {} | |
| linux_appimage = (linux.get("installers") or {}).get("appimage") or {} | |
| linux_deb = (linux.get("installers") or {}).get("deb") or {} | |
| required = [ | |
| ("macos app", mac_app), | |
| ("linux appimage", linux_appimage), | |
| ("linux deb", linux_deb), | |
| ] | |
| missing = [name for name, item in required if not item.get("asset_name") or not item.get("signature")] | |
| if missing: | |
| raise SystemExit(f"Missing updater metadata: {', '.join(missing)}") | |
| def pick_release_asset(suffix: str) -> dict: | |
| matches = [ | |
| asset for asset in assets | |
| if isinstance(asset, dict) and (asset.get("name", "") or "").endswith(suffix) | |
| ] | |
| if len(matches) != 1: | |
| names = [asset.get("name", "") for asset in assets if isinstance(asset, dict)] | |
| raise SystemExit( | |
| f"Expected exactly one release asset with suffix {suffix}, found {len(matches)}. Assets: {names}" | |
| ) | |
| download_url = matches[0].get("browser_download_url", "") or "" | |
| if not download_url: | |
| raise SystemExit(f"Release asset {matches[0].get('name', '')} is missing browser_download_url") | |
| return { | |
| "asset_name": matches[0].get("name", "") or "", | |
| "url": download_url, | |
| } | |
| mac_release_asset = pick_release_asset(".app.tar.gz") | |
| linux_appimage_release_asset = pick_release_asset(".AppImage") | |
| linux_deb_release_asset = pick_release_asset(".deb") | |
| platforms = { | |
| "darwin-aarch64-app": {"url": mac_release_asset["url"], "signature": mac_app["signature"]}, | |
| "darwin-x86_64-app": {"url": mac_release_asset["url"], "signature": mac_app["signature"]}, | |
| "linux-x86_64-appimage": {"url": linux_appimage_release_asset["url"], "signature": linux_appimage["signature"]}, | |
| "linux-x86_64-deb": {"url": linux_deb_release_asset["url"], "signature": linux_deb["signature"]}, | |
| } | |
| latest = { | |
| "version": version, | |
| "notes": body, | |
| "pub_date": pub_date, | |
| "platforms": platforms, | |
| } | |
| pathlib.Path("latest.json").write_text(json.dumps(latest, indent=2), encoding="utf-8") | |
| print("Wrote latest.json") | |
| PY | |
| - name: Upload latest.json to GitHub Release | |
| if: ${{ env.HAS_SIGNING_KEY == 'true' }} | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| for attempt in 1 2 3; do | |
| if gh release upload "${{ github.ref_name }}" latest.json --clobber --repo "${{ github.repository }}"; then | |
| break | |
| fi | |
| if [ "$attempt" -eq 3 ]; then | |
| echo "Failed to upload latest.json after 3 attempts." | |
| exit 1 | |
| fi | |
| sleep_seconds=$((attempt * 10)) | |
| echo "Upload failed, retrying in ${sleep_seconds}s (attempt ${attempt}/3)" | |
| sleep "$sleep_seconds" | |
| done | |
| promote-release: | |
| needs: publish-latest-json | |
| runs-on: ubuntu-24.04 | |
| env: | |
| HAS_SIGNING_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} | |
| steps: | |
| - name: Verify release assets are ready | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| TAG: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| gh release view "${TAG}" --repo "${REPO}" --json assets > release-assets.json | |
| python3 - <<'PY' | |
| import json | |
| import os | |
| require_latest_json = os.environ.get("HAS_SIGNING_KEY", "").lower() == "true" | |
| with open("release-assets.json", "r", encoding="utf-8") as fh: | |
| release = json.load(fh) | |
| names = [asset.get("name", "") for asset in release.get("assets") or []] | |
| def has_suffix(suffix: str) -> bool: | |
| return any(name.endswith(suffix) for name in names) | |
| missing = [] | |
| if not has_suffix(".dmg"): | |
| missing.append(".dmg") | |
| if not has_suffix(".AppImage"): | |
| missing.append(".AppImage") | |
| if not has_suffix(".deb"): | |
| missing.append(".deb") | |
| if require_latest_json and "latest.json" not in names: | |
| missing.append("latest.json") | |
| if missing: | |
| raise SystemExit(f"Release assets are incomplete: {', '.join(missing)}") | |
| PY | |
| - name: Promote release to latest stable | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REPO: ${{ github.repository }} | |
| TAG: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| RELEASE_ID="$(gh release view "${TAG}" --repo "${REPO}" --json databaseId --jq '.databaseId')" | |
| gh api --method PATCH "/repos/${REPO}/releases/${RELEASE_ID}" \ | |
| -F prerelease=false \ | |
| -F make_latest=true \ | |
| --silent |