chore(release): self-hosted updater on update.netralax.cloud

Switches the Tauri updater endpoint from GitHub Releases to a static
host. New Ed25519 pubkey (old private key was lost); existing 0.10.x
installs need one manual reinstall to pick up the new updater identity.

Release flow is now pnpm release <version> <notes> which bumps,
builds + signs locally, scps artifacts to the server, commits, tags.
GitHub workflow stays as workflow_dispatch backup (Windows only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 19:17:58 +02:00
parent a38e2f96c0
commit 500f1c4bc2
6 changed files with 198 additions and 30 deletions
+21
View File
@@ -0,0 +1,21 @@
# Copy to .env.release (gitignored) and fill in.
# Consumed by scripts/release.mjs.
# Absolute path to the private key file produced by `tauri signer generate`.
TAURI_SIGNING_PRIVATE_KEY_PATH=C:/Users/denni/.tauri/chatapp.key
# Password set when generating the key. Leave empty if none.
TAURI_SIGNING_PRIVATE_KEY_PASSWORD=
# Host serving latest.json + installer artifacts over HTTPS.
UPDATE_HOST=update.netralax.cloud
# SSH user on UPDATE_HOST with write access to UPDATE_REMOTE_PATH.
UPDATE_SSH_USER=chatapp-deploy
# Optional: path to the SSH private key. Omit to fall back on ssh-agent or the
# default id_rsa.
UPDATE_SSH_KEY=
# Absolute path on the server where windows/ artifacts + latest.json live.
UPDATE_REMOTE_PATH=/var/www/updates/windows
+16 -28
View File
@@ -1,35 +1,27 @@
name: Release desktop app
name: Release desktop app (manual backup)
# Tag a version to trigger a release:
# git tag v0.1.0 && git push --tags
#
# Produces signed Tauri bundles for macOS (arm + intel), Windows, and Linux,
# uploads them to a GitHub Release, and publishes `latest.json` for the
# updater plugin to discover.
# Self-hosted updates run from scripts/release.mjs on Dennis's Windows box.
# This workflow is kept as a manual backup — trigger it from the Actions tab
# if the local build host is unavailable.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Tag to build (e.g. v0.10.2) — must already exist"
required: true
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: macos-14 # universal binary covers both Intel + Apple Silicon
args: "--target universal-apple-darwin --bundles app,updater"
- platform: windows-latest
args: ""
runs-on: ${{ matrix.platform }}
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -42,8 +34,6 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.platform == 'macos-14' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Install JS deps
run: pnpm install --frozen-lockfile
@@ -54,18 +44,16 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
# Client-side env vars baked into the bundle — paste your prod values
# into the repo's Actions → Secrets so releases point at prod.
VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
VITE_AUTH_REDIRECT_URL: ${{ secrets.VITE_AUTH_REDIRECT_URL }}
VITE_LIVEKIT_URL: ${{ secrets.VITE_LIVEKIT_URL }}
with:
projectPath: apps/desktop
tagName: ${{ github.ref_name }}
releaseName: "ChatApp ${{ github.ref_name }}"
releaseBody: "See the assets below to download this version."
tagName: ${{ inputs.tag }}
releaseName: "ChatApp ${{ inputs.tag }}"
releaseBody: "Manual build — copy .nsis.zip/.sig/latest.json to the update host."
releaseDraft: true
prerelease: false
tauriScript: pnpm exec tauri
args: ${{ matrix.args }}
args: "--bundles nsis"
+2
View File
@@ -15,7 +15,9 @@ out/
.env
.env.local
.env.*.local
.env.release
!.env.example
!.env.release.example
# Expo
.expo/
+2 -2
View File
@@ -42,8 +42,8 @@
},
"plugins": {
"updater": {
"endpoints": ["https://github.com/byGalax/chat-app/releases/latest/download/latest.json"],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5N0U0RDcxOTU2OEQ0QUUKUldTdTFHaVZjVTErU1ZuMk1lWXBUbEcyS1RHYzJQN3k4VDdiUGRvRnVJYVJKR3BxWG1xcENpdlYK",
"endpoints": ["https://update.netralax.cloud/windows/latest.json"],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEI1Mzc0QjVBQUZEQTA3RUIKUldUckI5cXZXa3MzdGM3QkE4WWFPd3NnVzRZeXdpcUM0eUtjRDlGN09ySEdzNXhLNlo3azBPajYK",
"windows": {
"installMode": "passive"
}
+1
View File
@@ -26,6 +26,7 @@
"desktop": "pnpm --filter @chat-app/desktop",
"desktop:dev": "pnpm --filter @chat-app/desktop dev",
"desktop:build": "pnpm --filter @chat-app/desktop build",
"release": "node scripts/release.mjs",
"db:types": "supabase gen types typescript --local > packages/db-types/src/index.ts",
"prod:migrate": "./scripts/prod/push-migrations.sh",
"prod:deploy-fn": "./scripts/prod/push-edge-function.sh",
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
// Release script — builds the Windows desktop installer, signs it with the
// Tauri updater key, and uploads the artifacts + latest.json to the update
// host over scp. Reads credentials from .env.release (not committed).
//
// Usage:
// pnpm release 0.10.2 "Ringtone cap auf 8 MB, bugfixes"
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
const [, , versionArg, ...notesParts] = process.argv;
if (!versionArg || !/^\d+\.\d+\.\d+$/.test(versionArg)) {
console.error('Usage: pnpm release <x.y.z> "release notes"');
process.exit(1);
}
const notes = notesParts.join(' ').trim() || `Release ${versionArg}`;
const envPath = join(ROOT, '.env.release');
if (!existsSync(envPath)) {
console.error('.env.release missing. Copy .env.release.example and fill it in.');
process.exit(1);
}
const env = Object.fromEntries(
readFileSync(envPath, 'utf8')
.split('\n')
.map((l) => l.trim())
.filter((l) => l && !l.startsWith('#'))
.map((l) => {
const i = l.indexOf('=');
return [l.slice(0, i).trim(), l.slice(i + 1).trim()];
}),
);
const required = [
'TAURI_SIGNING_PRIVATE_KEY_PATH',
'UPDATE_HOST',
'UPDATE_SSH_USER',
'UPDATE_REMOTE_PATH',
];
for (const key of required) {
if (!env[key]) {
console.error(`Missing ${key} in .env.release`);
process.exit(1);
}
}
if (!existsSync(env.TAURI_SIGNING_PRIVATE_KEY_PATH)) {
console.error(`Signing key not found at ${env.TAURI_SIGNING_PRIVATE_KEY_PATH}`);
process.exit(1);
}
const gitStatus = execSync('git status --porcelain', { cwd: ROOT, encoding: 'utf8' });
if (gitStatus.trim()) {
console.error('Working tree not clean. Commit or stash first.');
console.error(gitStatus);
process.exit(1);
}
const pkgJsonPath = join(ROOT, 'apps/desktop/package.json');
const tauriConfPath = join(ROOT, 'apps/desktop/src-tauri/tauri.conf.json');
const cargoTomlPath = join(ROOT, 'apps/desktop/src-tauri/Cargo.toml');
function bumpJson(path, version) {
const obj = JSON.parse(readFileSync(path, 'utf8'));
obj.version = version;
writeFileSync(path, JSON.stringify(obj, null, 2) + '\n', 'utf8');
}
function bumpCargo(path, version) {
const text = readFileSync(path, 'utf8');
const next = text.replace(/^version = "[^"]+"$/m, `version = "${version}"`);
if (next === text) throw new Error(`No version line found in ${path}`);
writeFileSync(path, next, 'utf8');
}
bumpJson(pkgJsonPath, versionArg);
bumpJson(tauriConfPath, versionArg);
bumpCargo(cargoTomlPath, versionArg);
console.log(`Version -> ${versionArg}. Building NSIS bundle…`);
const signingKey = readFileSync(env.TAURI_SIGNING_PRIVATE_KEY_PATH, 'utf8').trim();
const buildEnv = {
...process.env,
TAURI_SIGNING_PRIVATE_KEY: signingKey,
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD ?? '',
};
execSync('pnpm --filter @chat-app/desktop build -- --bundles nsis', {
cwd: ROOT,
env: buildEnv,
stdio: 'inherit',
});
const bundleDir = join(ROOT, 'apps/desktop/src-tauri/target/release/bundle/nsis');
const zipName = `ChatApp_${versionArg}_x64-setup.nsis.zip`;
const exeName = `ChatApp_${versionArg}_x64-setup.exe`;
const sigName = `${zipName}.sig`;
const zipPath = join(bundleDir, zipName);
const exePath = join(bundleDir, exeName);
const sigPath = join(bundleDir, sigName);
for (const p of [zipPath, sigPath]) {
if (!existsSync(p)) {
console.error(`Missing build artifact: ${p}`);
process.exit(1);
}
}
const signature = readFileSync(sigPath, 'utf8').trim();
const latest = {
version: versionArg,
notes,
pub_date: new Date().toISOString(),
platforms: {
'windows-x86_64': {
signature,
url: `https://${env.UPDATE_HOST}/windows/${zipName}`,
},
},
};
const latestJsonPath = join(bundleDir, 'latest.json');
writeFileSync(latestJsonPath, JSON.stringify(latest, null, 2), 'utf8');
const sshTarget = `${env.UPDATE_SSH_USER}@${env.UPDATE_HOST}`;
const keyFlag = env.UPDATE_SSH_KEY ? ` -i "${env.UPDATE_SSH_KEY}"` : '';
function scp(localPath) {
execSync(`scp${keyFlag} "${localPath}" ${sshTarget}:${env.UPDATE_REMOTE_PATH}/`, {
cwd: ROOT,
stdio: 'inherit',
});
}
console.log('Uploading artifacts (JSON last so clients never see a stale ref)…');
scp(zipPath);
scp(sigPath);
// Also ship the bare installer so friends can download it directly via browser
// without going through the updater — useful for the very first install.
if (existsSync(exePath)) scp(exePath);
scp(latestJsonPath);
execSync(
`git add apps/desktop/package.json apps/desktop/src-tauri/tauri.conf.json apps/desktop/src-tauri/Cargo.toml`,
{ cwd: ROOT, stdio: 'inherit' },
);
execSync(`git commit -m "chore(desktop): release v${versionArg}"`, {
cwd: ROOT,
stdio: 'inherit',
});
execSync(`git tag v${versionArg}`, { cwd: ROOT, stdio: 'inherit' });
console.log(`\nReleased v${versionArg}`);
console.log(` Manifest: https://${env.UPDATE_HOST}/windows/latest.json`);
console.log(` Installer: https://${env.UPDATE_HOST}/windows/${exeName}`);
console.log(` Run 'git push && git push --tags' to sync to remote.`);