Skip to content

chore: prepare version 0.0.86 and patch frontend dependency advisories #36

chore: prepare version 0.0.86 and patch frontend dependency advisories

chore: prepare version 0.0.86 and patch frontend dependency advisories #36

Workflow file for this run

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