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(); +}