Auto-update service
Serve auto-updates for Helm macOS apps from the Helm marketing site (helm.e-labs.co.uk) instead of a separate Cloudflare Worker. Simpler: one deployment, one domain, one set of secrets.
Why this approach
Section titled “Why this approach”The Helm repo is private, so release binaries aren’t publicly downloadable. An installed Helm DJ app can’t carry a GitHub token (it’d leak to every user). So we need a server-side proxy that:
- Knows the GitHub token.
- Accepts an update-check request from the app.
- Queries GitHub, finds the latest release, returns
latest.jsonshaped for Tauri. - Streams binary downloads through.
The website already has: TLS, hosting (Vercel), env-var secrets, a deploy pipeline, and the main domain. Two small API routes and one env var turn it into the update service.
Architecture
Section titled “Architecture”installed app │ GET https://helm.e-labs.co.uk/api/updates/darwin-aarch64/0.1.0 ▼Next.js API route (Vercel) │ uses GITHUB_TOKEN to call GitHub API │ returns { version, signature, url: "/api/downloads/<id>" } ▼installed app │ GET https://helm.e-labs.co.uk/api/downloads/<asset_id> ▼Next.js proxy route (Vercel) │ streams asset from github.com with GITHUB_TOKEN ▼installed app → verifies Tauri signature → installs updatePrerequisites
Section titled “Prerequisites”- Helm website deployed on Vercel (already done — see next.config.mjs).
- Tauri updater keypair generated and
pubkeycommitted to tauri.conf.json — already done ondj-beta-v0.3. - Apple signing secrets wired (see the Apple Developer setup runbook).
Authentication
Section titled “Authentication”Both routes require the X-Helm-Client-Key header. See the Licence system spec for rationale and the shared requireClientKey middleware. The header is the same one the licence check uses — one HELM_CLIENT_KEY env var covers both endpoints.
Apps must embed HELM_CLIENT_KEY at build time and send it on every update check and asset download. For the Tauri updater plugin, configure headers via the Rust UpdaterExt builder API rather than tauri.conf.json so the secret doesn’t end up committed to git.
Part 1 — Create a fine-grained GitHub Personal Access Token
Section titled “Part 1 — Create a fine-grained GitHub Personal Access Token”Not a classic PAT — use fine-grained so scope is narrow.
-
Go to https://github.com/settings/personal-access-tokens/new.
-
Token name:
helm-website-updates. -
Expiration: 1 year (the longest GitHub allows for fine-grained tokens — you’ll rotate annually).
-
Resource owner:
E-Labs-io. -
Repository access: “Only select repositories” → choose
helm. -
Repository permissions: only grant Contents: Read. Leave everything else No access.
-
Click Generate token.
-
Copy the token immediately — shown once. Save in 1Password.
This token can only read release metadata and download assets from E-Labs-io/helm. It cannot push, modify issues, delete anything. Scoped to the minimum.
Part 2 — Add the updates API route
Section titled “Part 2 — Add the updates API route”Create website/src/app/api/updates/[platform]/[version]/route.ts:
import { NextRequest, NextResponse } from 'next/server';import { requireClientKey } from '@/lib/client-auth';
// Always run on the server, never cache between requests.export const runtime = 'nodejs';export const dynamic = 'force-dynamic';
const REPO = 'E-Labs-io/helm';
// Tag pattern for Helm DJ releases, e.g. `v0.2.1`.// When Cues starts shipping, add `[product]` segment and prefix tags// (see "Multi-product" appendix).const TAG_RE = /^v\d+\.\d+\.\d+$/;
type Params = { platform: string; version: string };
export async function GET( req: NextRequest, { params }: { params: Promise<Params> }) { const authErr = requireClientKey(req); if (authErr) return authErr;
const { platform, version } = await params;
const token = process.env.GITHUB_TOKEN; if (!token) { return NextResponse.json({ error: 'server not configured' }, { status: 500 }); }
// Fetch all releases (there may be several; latest isn't always `releases/latest` // if we ever add pre-release tags). const releasesResp = await fetch( `https://api.github.com/repos/${REPO}/releases?per_page=20`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', }, }, ); if (!releasesResp.ok) { return NextResponse.json({ error: 'upstream failure' }, { status: 502 }); }
const releases = (await releasesResp.json()) as GhRelease[]; const latest = releases .filter((r) => !r.draft && !r.prerelease && TAG_RE.test(r.tag_name)) .sort((a, b) => (a.published_at < b.published_at ? 1 : -1))[0];
if (!latest) { return new NextResponse(null, { status: 204 }); // no release yet }
const latestVersion = latest.tag_name.replace(/^v/, ''); if (latestVersion === version) { return new NextResponse(null, { status: 204 }); // up to date }
// Find the .app.tar.gz and .sig for this platform. const tarGz = latest.assets.find( (a) => a.name.endsWith('.app.tar.gz') && a.name.includes(platform), ); const sig = latest.assets.find( (a) => a.name.endsWith('.app.tar.gz.sig') && a.name.includes(platform), ); if (!tarGz || !sig) { return NextResponse.json( { error: `no asset for platform ${platform}` }, { status: 404 }, ); }
// Pull the signature contents inline (they're small). const sigResp = await fetch(sig.url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/octet-stream', }, }); if (!sigResp.ok) { return NextResponse.json({ error: 'signature fetch failed' }, { status: 502 }); } const signature = (await sigResp.text()).trim();
const origin = new URL(_req.url).origin;
return NextResponse.json({ version: latestVersion, notes: latest.body ?? '', pub_date: latest.published_at, platforms: { [platform]: { signature, url: `${origin}/api/downloads/${tarGz.id}`, }, }, });}
// Minimal GitHub API typings for the bits we use.type GhRelease = { tag_name: string; draft: boolean; prerelease: boolean; published_at: string; body: string | null; assets: Array<{ id: number; name: string; url: string }>;};Part 3 — Add the download proxy route
Section titled “Part 3 — Add the download proxy route”Create website/src/app/api/downloads/[asset_id]/route.ts:
import { NextRequest, NextResponse } from 'next/server';import { requireClientKey } from '@/lib/client-auth';
export const runtime = 'nodejs';export const dynamic = 'force-dynamic';
const REPO = 'E-Labs-io/helm';
export async function GET( req: NextRequest, { params }: { params: Promise<{ asset_id: string }> },) { const authErr = requireClientKey(req); if (authErr) return authErr;
const { asset_id } = await params; const token = process.env.GITHUB_TOKEN;
if (!token) { return NextResponse.json({ error: 'server not configured' }, { status: 500 }); } if (!/^\d+$/.test(asset_id)) { return NextResponse.json({ error: 'bad asset id' }, { status: 400 }); }
// Stream the asset binary from GitHub through to the client. const upstream = await fetch( `https://api.github.com/repos/${REPO}/releases/assets/${asset_id}`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/octet-stream', 'X-GitHub-Api-Version': '2022-11-28', }, // Follow redirects — GitHub redirects asset downloads to a signed URL. redirect: 'follow', }, );
if (!upstream.ok || !upstream.body) { return NextResponse.json( { error: 'upstream failure', status: upstream.status }, { status: upstream.status || 502 }, ); }
// Pass through the content-type and length. const headers = new Headers(); const ct = upstream.headers.get('content-type'); const cl = upstream.headers.get('content-length'); if (ct) headers.set('content-type', ct); if (cl) headers.set('content-length', cl);
return new NextResponse(upstream.body, { status: 200, headers });}Part 4 — Set GITHUB_TOKEN in Vercel
Section titled “Part 4 — Set GITHUB_TOKEN in Vercel”- Go to https://vercel.com/dashboard → find the
helm/ website project → Settings. - Left sidebar → Environment Variables.
- Name:
GITHUB_TOKEN. - Value: the fine-grained PAT from Part 1.
- Environments: tick Production, Preview, Development.
- Save.
- Trigger a redeploy so the new env var takes effect — either push a tiny commit to the website branch, or in Vercel: Deployments → latest → Redeploy.
Part 5 — Update tauri.conf.json
Section titled “Part 5 — Update tauri.conf.json”On branch dj-beta-v0.3, edit products/dj/src-tauri/tauri.conf.json — change the updater endpoint:
"plugins": { "updater": { "endpoints": [ "https://updates.helm.e-labs.co.uk/v1/updates/{{target}}-{{arch}}/{{current_version}}" "https://helm.e-labs.co.uk/api/updates/{{target}}-{{arch}}/{{current_version}}" ], "pubkey": "dW50cnVzdGVk...",{{target}}-{{arch}} expands to things like darwin-aarch64 — the single path segment becomes the [platform] param in the API route.
Part 6 — Remove the Cloudflare manifest job from CI
Section titled “Part 6 — Remove the Cloudflare manifest job from CI”On dj-beta-v0.3, edit products/dj/.github/workflows/release.yml. The publish-manifest job (lines ~97 onwards) built a latest.json and uploaded it to the GitHub Release for the Worker to read. You don’t need it any more — the website builds latest.json on demand.
Delete the entire publish-manifest job. The build job stays (it produces the .app.tar.gz and .sig assets — those are what the website serves).
Also delete products/dj/updater-worker/ entirely — that directory is the Cloudflare Worker code; it’s dead weight now.
Part 7 — Test
Section titled “Part 7 — Test”Dry release
Section titled “Dry release”Still on dj-beta-v0.3:
cd products/dj./scripts/release.sh patch # e.g. 0.2.0 → 0.2.1git push && git push --tagsWatch the Actions run. When it finishes, the GitHub Release should have:
Helm DJ_0.2.1_aarch64.app.tar.gzHelm DJ_0.2.1_aarch64.app.tar.gz.sigHelm DJ_0.2.1_x64.app.tar.gzHelm DJ_0.2.1_x64.app.tar.gz.sigHelm DJ_0.2.1_aarch64.dmgHelm DJ_0.2.1_x64.dmg
No latest.json — that’s expected now, the website generates it.
Curl the endpoint
Section titled “Curl the endpoint”# Simulate an old app on Apple Silicon asking for updatescurl -s https://helm.e-labs.co.uk/api/updates/darwin-aarch64/0.0.1 | jqExpected:
{ "version": "0.2.1", "notes": "...", "pub_date": "2026-04-18T...", "platforms": { "darwin-aarch64": { "signature": "dW50cnVzdGVkIGNvbW1lbnQ6...", "url": "https://helm.e-labs.co.uk/api/downloads/123456789" } }}Simulate an up-to-date app:
curl -I https://helm.e-labs.co.uk/api/updates/darwin-aarch64/0.2.1# HTTP/2 204Download check
Section titled “Download check”curl -L -o /tmp/helm-dj.app.tar.gz \ "https://helm.e-labs.co.uk/api/downloads/123456789"tar tzf /tmp/helm-dj.app.tar.gz | head# Should list Helm DJ.app/ contentsEnd-to-end
Section titled “End-to-end”Install an older signed DMG on a test Mac. Launch the app. Within a few seconds (Tauri’s default check interval on start), you should see the in-app update prompt. Accept. Watch it download, verify signature, restart into the new version.
Appendix — Multi-product extension (when Helm Cues starts shipping)
Section titled “Appendix — Multi-product extension (when Helm Cues starts shipping)”One URL scheme, one tag convention, one change:
- Move API route:
website/src/app/api/updates/[product]/[platform]/[version]/route.ts. Filter releases by tag prefix:const TAG_RE = new RegExp(`^${product}-v\\d+\\.\\d+\\.\\d+$`); - Update release tag format. Change products/dj/scripts/release.sh and any per-product equivalents to tag
helm-dj-v0.2.1(notv0.2.1). Update the workflow trigger glob to match:tags: ['helm-dj-v*.*.*']. - Update tauri.conf.json endpoint per product:
https://helm.e-labs.co.uk/api/updates/helm-dj/{{target}}-{{arch}}/{{current_version}}https://helm.e-labs.co.uk/api/updates/helm-cues/{{target}}-{{arch}}/{{current_version}}
The download route doesn’t need to change — asset_id is globally unique across the repo.
Appendix — Rate limits
Section titled “Appendix — Rate limits”GitHub’s API limit on authenticated requests is 5,000/hour per token. Each app update check costs ~2 API calls (list releases + fetch signature). That’s 2,500 update checks per hour across all users. Plenty for beta — revisit if we ever cross a few hundred active users.
If it becomes an issue, add a short-lived cache (e.g. 60 seconds) to the updates route using Vercel’s Edge Config or a simple unstable_cache.
Appendix — Security notes
Section titled “Appendix — Security notes”- The PAT in
GITHUB_TOKENhas Contents: Read only. It can’t push, can’t modify, can’t leak secrets. - Binary downloads go through the website’s TLS — users don’t see any GitHub URL or token.
- If the token leaks, revoke at https://github.com/settings/personal-access-tokens. Generate a new one, update the Vercel env var, redeploy. No user-visible breakage.
- Don’t log the token in route handlers. Don’t include it in error messages.
console.log(process.env.GITHUB_TOKEN)in production logs is an incident.