Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 82 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 48 additions & 47 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<tag>",
"notes": "...",
"pub_date": "<ISO>",
"platforms": {
"darwin-aarch64": {
"signature": "<contents of DeepCode_<v>_aarch64.dmg.sig>",
"url": "https://github.com/oratis/deepcode/releases/download/v<v>/DeepCode_<v>_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@<version>` shows the new version
- Verify: `https://github.com/oratis/deepcode/releases/tag/v<version>`
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
Expand Down
53 changes: 53 additions & 0 deletions scripts/gen-update-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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$/,
);
});
});
Loading
Loading