From 36472a0a34f6a886669a408110b97e3e3a5a8abb Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 9 Aug 2026 22:59:06 +0800 Subject: [PATCH] ci(release): produce the update feed the app has been polling for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tauri.conf.json` has carried `updater.active: true`, a committed public key and an endpoint at `releases/latest/download/latest.json` since the desktop app shipped. Nothing ever produced that file. The app polls, 404s, and silently never updates — which is indistinguishable, from the user's side, from an app that is already up to date. The pipeline now closes the loop: `validate` detects the signing key, `build-mac` flips `createUpdaterArtifacts` on and exports the key to `tauri build`, then locates the `.app.tar.gz` and its `.sig`, writes `latest.json`, and `github-release` attaches all three. Every part of it is gated on the key existing. `createUpdaterArtifacts` stays false in the committed config because `tauri build` fails outright when it is true with no key in the environment — leaving it on would break every credential-less release, which is the failure mode RELEASING.md already warned about. And when the key is absent the release body says the feed is missing, for the same reason the DMG and npm skips already say so. The manifest comes from a script with tests, not inline YAML. Its shape is a contract with the updater, and a wrong field name fails the way a missing file does: quietly, in the user's app, long after the release was cut. It refuses to emit an empty signature — the plausible mistake is globbing up a `.sig` that was never written — and pins the download URL at the tag rather than `/latest/`, so a manifest a client already fetched keeps resolving to the build its signature was made for. Not verifiable here: this needs a signing key and a macOS runner. The YAML parses, the step ordering is asserted, and the manifest builder is unit-tested; the build steps themselves are first exercised by the next tagged release. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 83 ++++++++++++++++++++++- CHANGELOG.md | 10 +++ docs/RELEASING.md | 95 +++++++++++++------------- scripts/gen-update-manifest.test.ts | 53 +++++++++++++++ scripts/gen-update-manifest.ts | 101 ++++++++++++++++++++++++++++ 5 files changed, 294 insertions(+), 48 deletions(-) create mode 100644 scripts/gen-update-manifest.test.ts create mode 100644 scripts/gen-update-manifest.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1498914..60bc746 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: is_mandatory: ${{ steps.version.outputs.is_mandatory }} has_apple: ${{ steps.credentials.outputs.has_apple }} has_npm: ${{ steps.credentials.outputs.has_npm }} + has_updater: ${{ steps.credentials.outputs.has_updater }} steps: - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 @@ -70,6 +71,15 @@ jobs: echo "has_apple=false" >> "$GITHUB_OUTPUT" echo "::warning::Apple signing secrets absent — skipping the Mac client build. See docs/RELEASING.md." fi + # The updater is a third, independent credential set. Without it the + # DMG still builds and ships — users just download it by hand, which + # is exactly what they do today. + if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" ]; then + echo "has_updater=true" >> "$GITHUB_OUTPUT" + else + echo "has_updater=false" >> "$GITHUB_OUTPUT" + echo "::warning::TAURI_SIGNING_PRIVATE_KEY absent — no update feed; users must download the DMG. See docs/RELEASING.md." + fi if [ -n "${{ secrets.NPM_TOKEN }}" ]; then echo "has_npm=true" >> "$GITHUB_OUTPUT" else @@ -309,6 +319,26 @@ jobs: )); " + # `tauri build` FAILS outright if createUpdaterArtifacts is true and no + # signing key is in the environment, which is why the committed config + # leaves it off. Flipping it here — and only when the key exists — keeps a + # credential-less release building while a credentialed one produces the + # feed. + - name: Enable updater artifacts + if: needs.validate.outputs.has_updater == 'true' + run: | + node -e " + const fs=require('fs'); + const p='apps/desktop/src-tauri/tauri.conf.json'; + const c=JSON.parse(fs.readFileSync(p,'utf8')); + c.bundle.createUpdaterArtifacts = true; + fs.writeFileSync(p, JSON.stringify(c,null,2)+'\n'); + " + node -e " + const c=require('./apps/desktop/src-tauri/tauri.conf.json'); + if (c.bundle.createUpdaterArtifacts !== true) process.exit(1); + " + - name: Import Developer ID certificate env: CSC_LINK: ${{ secrets.CSC_LINK }} @@ -338,6 +368,11 @@ jobs: env: DEEPCODE_TARGET: aarch64-apple-darwin DEEPCODE_NOTARY_PROFILE: DEEPCODE_NOTARY + # Read by `tauri build` itself. Empty when no key is configured, in + # which case the step above left createUpdaterArtifacts off and these + # are never consulted. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: bash scripts/sign-and-notarize.sh - name: Stage release artifacts @@ -346,11 +381,48 @@ jobs: cp apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/DeepCode_${{ needs.validate.outputs.version }}_aarch64.dmg \ release-artifacts/DeepCode-${{ needs.validate.outputs.version }}-arm64.dmg + # The updater downloads the .app.tar.gz, not the DMG, and verifies it + # against the .sig with the public key committed in tauri.conf.json. + # Located by glob rather than a hardcoded path: the exact bundle filename + # is Tauri's to choose, and a wrong guess here would produce a manifest + # pointing at a file that was never uploaded. + - name: Stage updater bundle + manifest + if: needs.validate.outputs.has_updater == 'true' + env: + VERSION: ${{ needs.validate.outputs.version }} + run: | + bundle_dir=apps/desktop/src-tauri/target/aarch64-apple-darwin/release/bundle/macos + bundle=$(find "$bundle_dir" -name '*.app.tar.gz' -maxdepth 1 | head -n 1) + if [ -z "$bundle" ]; then + echo "::error::createUpdaterArtifacts was enabled but no .app.tar.gz was produced in $bundle_dir" + ls -la "$bundle_dir" || true + exit 1 + fi + if [ ! -f "$bundle.sig" ]; then + echo "::error::$bundle has no .sig — the signing key did not reach tauri build" + exit 1 + fi + cp "$bundle" "$bundle.sig" release-artifacts/ + npx tsx scripts/gen-update-manifest.ts \ + --version "$VERSION" \ + --bundle "release-artifacts/$(basename "$bundle")" \ + --repo "$GITHUB_REPOSITORY" \ + > release-artifacts/latest.json + cat release-artifacts/latest.json + - name: Upload artifacts uses: actions/upload-artifact@v7 with: name: mac-release - path: release-artifacts/DeepCode-*.dmg + # latest.json and the .app.tar.gz(.sig) are only present on a release + # that had a signing key; `if-no-files-found: ignore` keeps a + # credential-less release from failing here. + path: | + release-artifacts/DeepCode-*.dmg + release-artifacts/*.app.tar.gz + release-artifacts/*.app.tar.gz.sig + release-artifacts/latest.json + if-no-files-found: ignore # ---------------------------------------------------------------------- # GitHub Release — runs after Mac build so the DMG can be attached @@ -420,6 +492,12 @@ jobs: if [ "${{ needs.publish-cli.result }}" != "success" ]; then printf '\n> **Not published to npm.** `NPM_TOKEN` was not configured when this release was cut; install from source or the VSIX.\n' >> release-notes.md fi + # Without this the in-app updater silently keeps polling a 404, so say + # it on the page rather than leaving users to wonder why the banner + # never appears. + if [ "${{ needs.validate.outputs.has_updater }}" != "true" ] && [ "${{ needs.build-mac.result }}" = "success" ]; then + printf '\n> **No in-app update feed in this release.** The Tauri signing key was not configured when it was cut, so `latest.json` was not produced — download the DMG manually. See `docs/RELEASING.md`.\n' >> release-notes.md + fi cat release-notes.md - name: Create GitHub Release @@ -432,3 +510,6 @@ jobs: files: | release-artifacts/*.dmg release-artifacts/*.vsix + release-artifacts/*.app.tar.gz + release-artifacts/*.app.tar.gz.sig + release-artifacts/latest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c25a40..05497a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 they name a day, not a moment. A trigger decides when, never what may happen: every scheduled run still goes through the unattended clamp. +- **The in-app updater has a feed.** `tauri.conf.json` has had + `updater.active: true` and a committed public key since the desktop app + shipped, pointing at a `latest.json` that nothing ever produced — so the app + polled, 404ed, and silently never updated. The release pipeline now enables + updater artifacts, signs them, writes the manifest and attaches it, all gated + on a signing key being present so a credential-less release still builds. When + the key is absent the release body says the feed is missing, because an + updater polling a 404 forever looks identical to one that has found no update. + Generating the key pair remains yours — see `docs/RELEASING.md`. + ### 🔒 Security - **A sub-agent did not inherit the file contract.** The `Task` delegation diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 5193021..ed7d357 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -140,59 +140,60 @@ Tag format determines the channel + publish target: The `+security.X` suffix sets `is_mandatory=true` in the release output so the Tauri updater can show a red "must update" banner. -## Auto-update feed (NOT yet wired — do this before relying on in-app updates) - -`tauri.conf.json` already has `plugins.updater.active: true` with a committed -**public** key and an endpoint at the release's `latest.json`. Three pieces are -still missing, so **in-app auto-update will not work until they're done** (users -must download the DMG manually): - -1. **Tauri updater signing key.** `tauri.conf.json#plugins.updater.pubkey` is the - public half. The matching **private key** must exist and be added as a GitHub - secret. If you have it, store it; if not, regenerate the pair (this changes the - pubkey, so the first build after is a clean break for existing installs): - - ```bash - pnpm --filter @deepcode/desktop exec tauri signer generate -w deepcode-updater.key - # → paste the PUBLIC key into tauri.conf.json#plugins.updater.pubkey - # → add the PRIVATE key file contents as the secret below (never commit it) - ``` - - Add two secrets: `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. - -2. **Enable updater artifacts.** Set `bundle.createUpdaterArtifacts: true` in - `tauri.conf.json`. ⚠️ Only flip this together with step 1 — `tauri build` will - **fail** if `createUpdaterArtifacts` is true but no signing key is present in - the env. (This is why it's left off today: the plain DMG build works without a - key.) - -3. **Generate + upload `latest.json`.** Add a step to `.github/workflows/release.yml` - (build-mac job) that, after signing, writes `latest.json` matching Tauri v2's - schema and uploads it to the release: - ```json - { - "version": "", - "notes": "...", - "pub_date": "", - "platforms": { - "darwin-aarch64": { - "signature": "_aarch64.dmg.sig>", - "url": "https://github.com/oratis/deepcode/releases/download/v/DeepCode__aarch64.dmg" - } - } - } - ``` - The `.sig` is produced by the signed build (step 2). The build env must export - `TAURI_SIGNING_PRIVATE_KEY` + `..._PASSWORD` so the artifact is signed. - -Until all three land, ship the DMG (notarized, works today) and tell users to -download manually; the "Relaunch to update" flow lights up once the feed exists. +## Auto-update feed + +The pipeline produces `latest.json` and the signed update bundle **as soon as a +signing key exists**. One thing is left, and only you can do it: + +**Generate the updater key pair and add it as a secret.** + +```bash +pnpm --filter @deepcode/desktop exec tauri signer generate -w deepcode-updater.key +# → paste the PUBLIC key into tauri.conf.json#plugins.updater.pubkey +# → add the PRIVATE key file contents as TAURI_SIGNING_PRIVATE_KEY (never commit it) +# → add its passphrase as TAURI_SIGNING_PRIVATE_KEY_PASSWORD +``` + +`tauri.conf.json` already carries a committed public key. If the private half is +lost, regenerating changes the public key, and **existing installs will refuse +every update signed by the new one** — they verify against the key they shipped +with. That is a clean break requiring a manual re-download, so treat the private +key as unrecoverable-if-lost, not as something to regenerate casually. + +### What the pipeline does with it + +| Step | Where | +| --------------------------------------------------- | ----------------------------- | +| Detect `TAURI_SIGNING_PRIVATE_KEY` → `has_updater` | `validate` | +| Flip `bundle.createUpdaterArtifacts` to true | `build-mac`, before the build | +| Export the signing key to `tauri build` | `build-mac` | +| Locate `*.app.tar.gz` + `.sig`, write `latest.json` | `build-mac`, after signing | +| Attach all three to the release | `github-release` | + +`createUpdaterArtifacts` stays **false in the committed config** on purpose: +`tauri build` fails outright when it is true and no key is in the environment, +so leaving it on would break every credential-less release. CI flips it, and +only when the key is there. + +Without the key the DMG still builds and ships — and the release body says the +feed is missing, because an updater that silently polls a 404 forever looks +identical to one that has simply found no update. + +The manifest is generated by [`scripts/gen-update-manifest.ts`](../scripts/gen-update-manifest.ts) +rather than inline YAML: its shape is a contract with the updater, and a wrong +field name fails the way a missing file does — quietly, in the user's app, long +after the release was cut. It refuses to emit an empty signature, and it pins +the download URL at the tag rather than `/latest/` so a manifest a client +already fetched keeps resolving to the build its signature was made for. ## After a release - Verify: `npm view @oratis/deepcode@` shows the new version - Verify: `https://github.com/oratis/deepcode/releases/tag/v` has the DMG and version-matched VSIX attached +- With an updater key configured: `curl -sSL https://github.com/oratis/deepcode/releases/latest/download/latest.json` + returns the manifest — that URL is exactly what the app polls, so fetching it + yourself is the whole test - Optional: announce in the README / homepage ## Local rehearsal diff --git a/scripts/gen-update-manifest.test.ts b/scripts/gen-update-manifest.test.ts new file mode 100644 index 0000000..81c337b --- /dev/null +++ b/scripts/gen-update-manifest.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { buildManifest } from './gen-update-manifest.js'; + +const base = { + version: '0.3.1', + bundlePath: '/build/bundle/macos/DeepCode.app.tar.gz', + signature: 'dW50cnVzdGVkIGNvbW1lbnQ6…\n', + repo: 'oratis/deepcode', + pubDate: '2026-08-09T12:00:00.000Z', +}; + +describe('buildManifest', () => { + it('produces the shape the Tauri updater expects', () => { + expect(buildManifest(base)).toEqual({ + version: '0.3.1', + pub_date: '2026-08-09T12:00:00.000Z', + platforms: { + 'darwin-aarch64': { + signature: 'dW50cnVzdGVkIGNvbW1lbnQ6…', + url: 'https://github.com/oratis/deepcode/releases/download/v0.3.1/DeepCode.app.tar.gz', + }, + }, + }); + }); + + it('pins the download at the tag, not at /latest/', () => { + // A client that already fetched this manifest has to keep resolving to the + // build the signature was made for, even after a newer release exists. + expect(buildManifest(base).platforms['darwin-aarch64']!.url).toContain('/download/v0.3.1/'); + expect(buildManifest(base).platforms['darwin-aarch64']!.url).not.toContain('/latest/'); + }); + + it('refuses an empty signature', () => { + // The plausible mistake is globbing up a `.sig` that was never written + // because the signing key was missing. A manifest with an empty signature + // is rejected by every client — better to fail the release than to publish + // an update nobody can install. + expect(() => buildManifest({ ...base, signature: ' \n' })).toThrow(/empty signature/); + }); + + it('omits notes rather than emitting an empty one', () => { + expect(buildManifest(base).notes).toBeUndefined(); + expect(buildManifest({ ...base, notes: 'Security fix' }).notes).toBe('Security fix'); + }); + + it('names the uploaded file, not its build path', () => { + // The URL is a release asset; the local directory it was built in has no + // meaning to the client. + expect(buildManifest(base).platforms['darwin-aarch64']!.url).toMatch( + /\/DeepCode\.app\.tar\.gz$/, + ); + }); +}); diff --git a/scripts/gen-update-manifest.ts b/scripts/gen-update-manifest.ts new file mode 100644 index 0000000..b611d49 --- /dev/null +++ b/scripts/gen-update-manifest.ts @@ -0,0 +1,101 @@ +#!/usr/bin/env node +// gen-update-manifest — the `latest.json` the Tauri updater polls. +// +// Usage: +// tsx scripts/gen-update-manifest.ts \ +// --version 0.3.1 --bundle \ +// --repo oratis/deepcode --pub-date 2026-08-09T00:00:00.000Z +// +// `tauri.conf.json#plugins.updater.endpoints` points at this file on the latest +// GitHub release. Without it the app polls, 404s, and silently never updates — +// which is the state DeepCode shipped in: `updater.active` was true and a public +// key was committed, but nothing ever produced the manifest. +// +// A separate script rather than inline YAML because the shape is a contract +// with the updater and a wrong field name fails the same way a missing file +// does: quietly, in the user's app, long after the release is cut. + +import { readFileSync, statSync } from 'node:fs'; +import { basename } from 'node:path'; + +/** Tauri v2's manifest shape. Extra keys are not added — the client validates. */ +export interface UpdateManifest { + version: string; + pub_date: string; + platforms: Record; + notes?: string; +} + +export interface ManifestInput { + version: string; + /** Path to the `.app.tar.gz` — its sibling `.sig` is read from disk. */ + bundlePath: string; + signature: string; + repo: string; + pubDate: string; + /** Tauri's platform key. macOS arm64 is the only build we ship. */ + platform?: string; + notes?: string; +} + +export function buildManifest(input: ManifestInput): UpdateManifest { + if (!input.signature.trim()) { + // An empty signature produces a manifest the updater rejects for every + // user, and it would be produced by exactly the plausible mistake: globbing + // up a `.sig` that was never written because the key was missing. + throw new Error('refusing to write a manifest with an empty signature'); + } + const file = basename(input.bundlePath); + return { + version: input.version, + pub_date: input.pubDate, + ...(input.notes ? { notes: input.notes } : {}), + platforms: { + [input.platform ?? 'darwin-aarch64']: { + signature: input.signature.trim(), + // Pinned to the tag, not `/latest/`: a client that already downloaded + // this manifest must keep resolving to the build it was signed for, + // even after a newer release exists. + url: `https://github.com/${input.repo}/releases/download/v${input.version}/${file}`, + }, + }, + }; +} + +function arg(argv: string[], name: string): string | undefined { + const index = argv.indexOf(`--${name}`); + return index === -1 ? undefined : argv[index + 1]; +} + +function main(): void { + const argv = process.argv.slice(2); + const version = arg(argv, 'version'); + const bundlePath = arg(argv, 'bundle'); + const repo = arg(argv, 'repo') ?? process.env.GITHUB_REPOSITORY; + const pubDate = arg(argv, 'pub-date') ?? new Date().toISOString(); + + if (!version || !bundlePath || !repo) { + process.stderr.write( + 'Usage: gen-update-manifest --version --bundle [--repo owner/name]\n', + ); + process.exit(2); + } + + // Fail on a missing bundle rather than emitting a manifest pointing at a file + // that was never uploaded. + statSync(bundlePath); + const signature = readFileSync(`${bundlePath}.sig`, 'utf8'); + + process.stdout.write( + JSON.stringify( + buildManifest({ version, bundlePath, signature, repo, pubDate, notes: arg(argv, 'notes') }), + null, + 2, + ) + '\n', + ); +} + +const invoked = process.argv[1] ?? ''; +if (invoked.includes('gen-update-manifest')) { + main(); +}