Skip to content

Commit 140a38f

Browse files
committed
Feat(release): sign a desktop update manifest with every release
ZenNotes desktop could only ship the zn it was built with, so every CLI fix waited for a desktop release. From desktop 2.60.0 the app updates a zn it installed by itself, and it needs something it can trust to do that without a person in the loop. Each tag now also publishes terminal-release.json, the same schema as the desktop repo's apps/desktop/terminal-release.json pin, and terminal-release.json.sig, an Ed25519 signature over its exact bytes under key id zn-release-1. scripts/releasemanifest builds the manifest from GoReleaser's dist (strict checksums.txt, every archive rehashed, the host binary's --desktop-integration probe for the protocol), signs it from the ZN_RELEASE_SIGNING_KEY secret, and verifies it against the committed public key before the upload, so a release can never ship a manifest the desktop would reject. The commit comes from `<tag>^{commit}` because tags here are annotated. The tool also has keygen and verify, documented in CONTRIBUTING with what losing the key means. zn update on a desktop-owned install still refuses, now pointing at Settings > CLI > Check for updates; --check still reports. README and the docs describe the desktop path. Rehearsed on the real v0.6.1 assets: the manifest came out byte-identical to the desktop's hand-made pin, and a signature made with the production key verified both here and against the desktop's embedded key.
1 parent c88b12f commit 140a38f

13 files changed

Lines changed: 1201 additions & 8 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,18 @@ jobs:
2626
args: release --clean
2727
env:
2828
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29+
# ZenNotes desktop updates the CLI it manages by reading these two assets
30+
# from releases/latest/download/, and only trusts a manifest signed by a
31+
# key it ships with. A release without them is never offered to desktop.
32+
- name: Build and sign the desktop update manifest
33+
# Tags are annotated; ^{commit} names the commit itself whichever
34+
# object the push event reports.
35+
run: go run ./scripts/releasemanifest build -dist dist -tag "$GITHUB_REF_NAME" -commit "$(git rev-parse "$GITHUB_REF_NAME^{commit}")" -require-signature
36+
env:
37+
ZN_RELEASE_SIGNING_KEY: ${{ secrets.ZN_RELEASE_SIGNING_KEY }}
38+
- name: Verify the manifest against the committed public key
39+
run: go run ./scripts/releasemanifest verify -manifest dist/terminal-release.json -sig dist/terminal-release.json.sig -public-key-file packaging/release-signing/zn-release-1.pub
40+
- name: Upload the manifest and signature
41+
run: gh release upload "$GITHUB_REF_NAME" dist/terminal-release.json dist/terminal-release.json.sig --clobber
42+
env:
43+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

‎CONTRIBUTING.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,43 @@ OS. Cross-compilation alone does not exercise the service manager.
4343
Use copied executables for self-update tests. Releases and Homebrew installation
4444
files must not be replaced as a side effect of ordinary test commands.
4545

46+
## Releases and signing
47+
48+
A `v*` tag push runs [the release workflow](.github/workflows/release.yml).
49+
GoReleaser publishes the archives and `checksums.txt`; then
50+
`go run ./scripts/releasemanifest build` writes `terminal-release.json`, signs it
51+
into `terminal-release.json.sig`, verifies the pair against
52+
`packaging/release-signing/zn-release-1.pub` and uploads both to the release.
53+
ZenNotes desktop installs CLI updates only from a manifest signed by a key it
54+
ships with ([desktop integration](docs/desktop-integration.md#desktop-managed-updates)).
55+
56+
The workflow needs the `ZN_RELEASE_SIGNING_KEY` repository secret, the base64
57+
Ed25519 seed that `keygen` writes. Generate it outside the checkout:
58+
59+
```sh
60+
go run ./scripts/releasemanifest keygen -out ~/zn-release-1.key
61+
```
62+
63+
The file is created with mode 0600 and never overwritten. `keygen` prints only the
64+
key id and the public key: store the file's single line as the secret, save the
65+
printed public key as `packaging/release-signing/zn-release-1.pub`, and ship the
66+
same public key in ZenNotes desktop.
67+
68+
Back the seed up somewhere safe and offline. If it is lost, desktop builds cannot
69+
verify new CLI releases (they keep their current CLI) until a desktop release
70+
ships a new public key. A leaked seed needs the same rotation: create
71+
`zn-release-2` with `-key-id`, ship its public key in a desktop release first,
72+
then replace the secret and point the workflow's `-key-id` and
73+
`-public-key-file` at the new key.
74+
75+
To rehearse locally, build a snapshot and run the manifest step without a key;
76+
it writes an unsigned manifest and warns:
77+
78+
```sh
79+
goreleaser release --snapshot --clean
80+
go run ./scripts/releasemanifest build -dist dist -tag v<snapshot version> -commit "$(git rev-parse HEAD)"
81+
```
82+
4683
## Shared contracts
4784

4885
The desktop protocol remains `1`. Preserve note command names, successful JSON

‎README.md‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ vault too.
2929
> settings commands, scoped help, shell completion, structured errors, shared
3030
> cross-note yank registers, and copyable version reports. Upgrading from
3131
> **v0.5.0 or earlier?** Use Homebrew, Go, or a fresh release download once:
32-
> those versions do not yet have `zn update`. Desktop-managed installations get
33-
> the version bundled with a desktop app update.
32+
> those versions do not yet have `zn update`. From desktop 2.60.0, ZenNotes updates its
33+
> managed `zn` itself: a daily check installs newer releases only, and only from
34+
> a signed manifest. For those installations `zn update` points to that check.
3435
3536
## What you get
3637

@@ -74,9 +75,11 @@ zn tui
7475
Update with `brew update && brew upgrade zn`.
7576

7677
**Installed from the desktop app.** Desktop builds that include this terminal
77-
runtime update their managed `zn` installation when the app opens. Keep using the
78-
same command; `zn tui` opens the terminal app. Settings shows the installed version
79-
and offers Repair when needed. Desktop never replaces a Homebrew or manual install.
78+
runtime install their managed `zn` when the app opens, then (from desktop 2.60.0) check daily for a newer
79+
CLI release and install it once its signed manifest verifies. Keep using the
80+
same command; `zn tui` opens the terminal app. Settings shows the installed version,
81+
offers Repair when needed, and has Check for updates under Settings > CLI. Desktop
82+
never replaces a Homebrew or manual install.
8083

8184
Desktop-managed commands follow the desktop workspace even when the TUI remembers
8285
a different one. Standalone installations keep their own default. Use
@@ -419,7 +422,7 @@ The updater detects the owner of the executable being run:
419422
| --- | --- |
420423
| Homebrew | Runs `brew upgrade zn`; Homebrew selects its packaged version. Explicit version pinning is rejected. |
421424
| Go-installed module | Runs `go install github.com/ZenNotes/tui/cmd/zn@v<version>` into the existing executable directory. |
422-
| Desktop-managed | Directs you to update ZenNotes desktop, which owns the runtime. |
425+
| Desktop-managed | ZenNotes desktop 2.60.0 and later updates it: a daily check installs newer releases with a valid signed manifest. `zn update` refuses and points to Settings > CLI > Check for updates; `--check` still reports. |
423426
| Standalone macOS/Linux release | Verifies and extracts the release, probes version/protocol, retains `zn.previous`, then atomically replaces `zn`. |
424427
| Windows standalone / other package managers | Provides the release or owner-specific update instruction. |
425428

‎docs/desktop-integration.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,59 @@ The `zn` Go CLI and TUI expose desktop integration protocol 1 through
44
`zn --desktop-integration`. Desktop bundles a pinned release archive, verifies it,
55
and installs a persistent copy. No Node installation is needed for that copy.
66

7+
## Desktop-managed updates
8+
9+
The bundled pin is where a desktop install starts, not where it stays. ZenNotes
10+
desktop checks once a day (and on demand from Settings > CLI > Check for updates)
11+
for two assets of the latest ZenNotes/tui release:
12+
13+
```text
14+
https://github.com/ZenNotes/tui/releases/latest/download/terminal-release.json
15+
https://github.com/ZenNotes/tui/releases/latest/download/terminal-release.json.sig
16+
```
17+
18+
`terminal-release.json` has the same schema as the desktop's bundled pin
19+
(`apps/desktop/terminal-release.json` in the desktop repository): the repository,
20+
the integration protocol, the version, the source commit, and a URL and SHA-256
21+
for each of `darwin-arm64`, `darwin-x64`, `linux-arm64` and `linux-x64`. It is
22+
2-space indented JSON ending in one newline, byte for byte what
23+
`JSON.stringify(value, null, 2) + "\n"` writes, so a desktop release can adopt a
24+
verified manifest as its new pin unchanged.
25+
26+
The release workflow writes both files with `scripts/releasemanifest` after
27+
GoReleaser. It checks every archive against `checksums.txt` and takes `protocol`
28+
from the released Linux x64 binary's own `zn --desktop-integration` output, which
29+
must also report the tagged version. The signature file is:
30+
31+
```json
32+
{
33+
"keyId": "zn-release-1",
34+
"algorithm": "ed25519",
35+
"signature": "<base64 of the 64-byte Ed25519 signature>"
36+
}
37+
```
38+
39+
The signature covers the exact bytes of `terminal-release.json`. Desktop ships
40+
the public key for every key id it trusts; this repository keeps the matching copy
41+
at `packaging/release-signing/zn-release-1.pub` (base64 of the raw 32-byte key),
42+
which the workflow verifies against before uploading. A manifest with a missing,
43+
unknown or invalid signature is ignored, and so is a release without these assets.
44+
45+
Desktop installs a release only when its version is newer than the managed CLI
46+
and its `protocol` is one that desktop build supports, after the platform archive
47+
matches the manifest's SHA-256. The new version is installed next to the current
48+
one, and the previous version is kept so desktop can roll back to it. Desktop
49+
never downgrades and never touches a Homebrew, Go or manually installed `zn`.
50+
For a desktop-managed executable, `zn update` refuses to replace it and points to
51+
Settings > CLI > Check for updates; `zn update --check` still reports the latest
52+
release.
53+
54+
A protocol change still needs a desktop release. Desktop builds that do not
55+
support the new protocol keep their current CLI, so ship the desktop update that
56+
speaks it before relying on the new CLI. Rotating the signing key works the same
57+
way: the new public key must reach users in a desktop release before any CLI
58+
release is signed with it.
59+
760
## Workspace selection
861

962
Desktop-managed commands set `ZENNOTES_WORKSPACE_SOURCE=app`. They use desktop

‎docs/managed-servers.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ zn update --version 0.6.0
112112
| --- | --- |
113113
| Homebrew | Runs `brew upgrade zn`. Homebrew chooses the packaged version; pinning through `--version` is rejected. |
114114
| Go-installed module | Runs `go install github.com/ZenNotes/tui/cmd/zn@v<version>` into the existing executable directory. |
115-
| Desktop-managed | Directs you to update ZenNotes desktop, which owns the CLI runtime. |
115+
| Desktop-managed | ZenNotes desktop 2.60.0 and later updates it: a daily check installs newer releases with a valid signed manifest. `zn update` refuses and points to Settings > CLI > Check for updates; `--check` still reports. See [desktop integration](desktop-integration.md#desktop-managed-updates). |
116116
| Standalone macOS/Linux release | Verifies the archive, extracts only the expected executable, probes version/protocol, retains `zn.previous`, then atomically replaces `zn`. |
117117
| Windows standalone / other package managers | Provides the release or owner-specific update instruction. |
118118

‎internal/selfupdate/update.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func Detect(executable string, info *debug.BuildInfo) Installation {
3131
case strings.Contains(path, "/Cellar/zn/") || strings.Contains(path, "/Cellar/zennotes/"):
3232
i.Owner, i.Instruction = "homebrew", "brew upgrade zn"
3333
case strings.Contains(path, "/cli/terminal/versions/") || strings.Contains(path, ".app/Contents/"):
34-
i.Owner, i.Instruction = "desktop", "Update ZenNotes desktop; it owns and verifies this CLI runtime."
34+
i.Owner, i.Instruction = "desktop", "ZenNotes keeps this CLI up to date: it checks for new releases daily and installs them. To check now, open ZenNotes and choose Settings > CLI > Check for updates."
3535
case strings.HasPrefix(path, "/nix/store/") || strings.HasPrefix(path, "/snap/") || strings.HasPrefix(path, "/usr/bin/"):
3636
i.Owner, i.Instruction = "package-manager", "Update zn with the package manager that installed it."
3737
case goInstalled(info):

‎internal/selfupdate/update_test.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88
"runtime/debug"
9+
"strings"
910
"testing"
1011
)
1112

@@ -28,6 +29,12 @@ func TestInstallationOwnerDetection(t *testing.T) {
2829
if got := Detect("/tmp/zn", checkout); got.Owner != "standalone" {
2930
t.Fatalf("source-built executable misclassified: %+v", got)
3031
}
32+
// Desktop installs its own CLI updates, so `zn update` points to the one
33+
// control that triggers that check instead of replacing the file itself.
34+
desktop := Detect("/Applications/ZenNotes.app/Contents/Resources/zn-cli/zn", nil)
35+
if desktop.Owner != "desktop" || !strings.Contains(desktop.Instruction, "Settings > CLI > Check for updates") {
36+
t.Fatalf("desktop instruction: %+v", desktop)
37+
}
3138
}
3239

3340
func TestFailedCandidatePreservesOriginalExecutable(t *testing.T) {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
vNI+cTgFGgg/CKam4WX6jdHisaEbiIjEf3KDjT81+uE=

‎scripts/releasemanifest/main.go‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Command releasemanifest writes and signs terminal-release.json, the release
2+
// asset ZenNotes desktop reads to update the CLI it manages. The release
3+
// workflow runs it after GoReleaser; CONTRIBUTING.md describes the signing key.
4+
//
5+
// go run ./scripts/releasemanifest keygen -out <path>
6+
// go run ./scripts/releasemanifest build -dist dist -tag vX.Y.Z -commit <sha> -require-signature
7+
// go run ./scripts/releasemanifest verify -manifest <path> -sig <path> -public-key-file <path>
8+
package main
9+
10+
import (
11+
"errors"
12+
"flag"
13+
"fmt"
14+
"io"
15+
"os"
16+
"path/filepath"
17+
"runtime"
18+
)
19+
20+
const usage = `usage: releasemanifest <command> [flags]
21+
22+
keygen -out <path> [-key-id zn-release-1]
23+
build -dist <dir> -tag vX.Y.Z -commit <40 hex> [-out <path>] [-sig <path>]
24+
[-key-env ZN_RELEASE_SIGNING_KEY] [-key-id zn-release-1] [-require-signature]
25+
verify -manifest <path> -sig <path> -public-key-file <path>`
26+
27+
func main() {
28+
if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil {
29+
if !errors.Is(err, flag.ErrHelp) {
30+
fmt.Fprintln(os.Stderr, "releasemanifest: "+err.Error())
31+
}
32+
os.Exit(1)
33+
}
34+
}
35+
36+
func run(args []string, stdout, stderr io.Writer) error {
37+
if len(args) == 0 {
38+
return errors.New(usage)
39+
}
40+
fs := flag.NewFlagSet(args[0], flag.ContinueOnError)
41+
fs.SetOutput(stderr)
42+
parse := func() error {
43+
if err := fs.Parse(args[1:]); err != nil {
44+
return err
45+
}
46+
if fs.NArg() > 0 {
47+
return fmt.Errorf("%s: unexpected argument %q", args[0], fs.Arg(0))
48+
}
49+
return nil
50+
}
51+
switch args[0] {
52+
case "keygen":
53+
out := fs.String("out", "", "file that receives the base64 seed (created with mode 0600)")
54+
keyID := fs.String("key-id", defaultKeyID, "key id printed with the public key")
55+
if err := parse(); err != nil {
56+
return err
57+
}
58+
if *out == "" {
59+
return errors.New("keygen: -out is required")
60+
}
61+
return keygen(*out, *keyID, stdout)
62+
case "build":
63+
c := buildConfig{GOOS: runtime.GOOS, GOARCH: runtime.GOARCH}
64+
fs.StringVar(&c.Dist, "dist", "", "GoReleaser dist directory")
65+
fs.StringVar(&c.Tag, "tag", "", "release tag, vX.Y.Z")
66+
fs.StringVar(&c.Commit, "commit", "", "40-character source commit")
67+
fs.StringVar(&c.Out, "out", "", "manifest path (default <dist>/terminal-release.json)")
68+
fs.StringVar(&c.Sig, "sig", "", "signature path (default <dist>/terminal-release.json.sig)")
69+
fs.StringVar(&c.KeyEnv, "key-env", "ZN_RELEASE_SIGNING_KEY", "environment variable holding the base64 Ed25519 seed")
70+
fs.StringVar(&c.KeyID, "key-id", defaultKeyID, "key id recorded in the signature")
71+
fs.BoolVar(&c.RequireSignature, "require-signature", false, "fail when no signing key is set")
72+
if err := parse(); err != nil {
73+
return err
74+
}
75+
if c.Dist == "" || c.Tag == "" || c.Commit == "" {
76+
return errors.New("build: -dist, -tag and -commit are required")
77+
}
78+
if c.Out == "" {
79+
c.Out = filepath.Join(c.Dist, "terminal-release.json")
80+
}
81+
if c.Sig == "" {
82+
c.Sig = filepath.Join(c.Dist, "terminal-release.json.sig")
83+
}
84+
return build(c, stdout, stderr)
85+
case "verify":
86+
manifest := fs.String("manifest", "", "terminal-release.json")
87+
sig := fs.String("sig", "", "terminal-release.json.sig")
88+
pub := fs.String("public-key-file", "", "<key id>.pub holding the base64 public key")
89+
if err := parse(); err != nil {
90+
return err
91+
}
92+
if *manifest == "" || *sig == "" || *pub == "" {
93+
return errors.New("verify: -manifest, -sig and -public-key-file are required")
94+
}
95+
if err := verify(*manifest, *sig, *pub); err != nil {
96+
return err
97+
}
98+
fmt.Fprintf(stdout, "verified %s with %s\n", *manifest, *pub)
99+
return nil
100+
default:
101+
return fmt.Errorf("unknown command %q\n%s", args[0], usage)
102+
}
103+
}

0 commit comments

Comments
 (0)