Skip to content

Commit 77d9aa4

Browse files
adrienlacombeclaude
andcommitted
docs(android): add PUBLISHING.md runbook; restore zsp-format zapstore.yaml
PUBLISHING.md documents the full release flow: android-v* tag → CI GitHub release → zsp publish with NIP-46 bunker signing, plus every failure mode hit while publishing v1.5.0 (TTY confirmation needs -q, silent already-exists exits, nsec.app approval timeouts, kind-5 cleanup, and why the old Dart zapstore CLI no longer works against relay.zapstore.dev). Reverts zapstore.yaml to the schema zsp validates (release_source, website, tags as list) — the Dart-CLI format from 8dac3b12 broke zsp --check. README's Zapstore section now points at the runbook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9f8aa06 commit 77d9aa4

3 files changed

Lines changed: 160 additions & 75 deletions

File tree

android_dashboard/PUBLISHING.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Publishing the Android Dashboard
2+
3+
Two stages: a tag-driven GitHub release (CI builds the signed APK), then a
4+
Zapstore publish with **zsp**. Read this before improvising — every quirk below
5+
was hit in practice and cost real time.
6+
7+
## Stage 1 — GitHub release (CI)
8+
9+
1. Bump `versionCode` (+1) and `versionName` in `app/build.gradle.kts`.
10+
Convention: minor bump per feature release (`1.4.0``1.5.0`).
11+
2. Optional local sanity build (exercises R8 + signing like CI):
12+
```bash
13+
cd android_dashboard
14+
set -a; source keys/release-secrets.env; set +a
15+
export RELEASE_KEYSTORE=$PWD/keys/release.jks
16+
./gradlew :app:assembleRelease
17+
```
18+
3. Commit, push, tag, push the tag:
19+
```bash
20+
git tag android-vX.Y.Z && git push origin master android-vX.Y.Z
21+
```
22+
The `Android APK` workflow builds the signed APK and creates the GitHub
23+
release with asset `sandboxed-dashboard-android-vX.Y.Z.apk`.
24+
25+
> CI and local builds are **not byte-identical** (signing timestamps differ).
26+
> The canonical artifact is the GitHub release asset — always publish that one
27+
> to Zapstore, not a local build.
28+
29+
## Stage 2 — Zapstore (zsp)
30+
31+
Zapstore is a Nostr app store: publishing signs three events (app kind `32267`,
32+
release `30063`, software asset `3063`) to `wss://relay.zapstore.dev`, with the
33+
APK blob on `cdn.zapstore.dev` (Blossom).
34+
35+
**Use `~/go/bin/zsp` (v0.4.10+).** It is the only tool that currently works
36+
end-to-end: it emits the `apk_certificate_hash` tag the relay requires and its
37+
uploads to cdn.zapstore.dev succeed. Do NOT use the old Dart `zapstore` CLI
38+
(0.2.4) — it predates the relay's validation rules; see Appendix.
39+
40+
### Prerequisites
41+
42+
- `~/go/bin/zsp` installed, with its bunker pairing present under
43+
`~/Library/Application Support/zsp/bunker-keys/` (macOS). The maintainer's
44+
signing pubkey is `7ebbce1843a17cd778a5e169e3d2f679f5ac7b5125d1c43d265e190f7b27538c`.
45+
- The maintainer reachable: **every publish requires them to approve signing
46+
requests in nsec.app** (icon/APK Blossom auth + the three events). If a sign
47+
request sits unapproved, zsp fails with
48+
`failed to sign auth event: context canceled` — that is a timeout, not a
49+
pairing problem. Ask them to keep nsec.app open, then retry.
50+
- `zapstore.yaml` in this directory. zsp's schema: `release_source` (path to
51+
the APK), `repository`, `website`, `icon`, `name`, `summary`, `description`,
52+
`tags` (a **YAML list** — the Dart CLI wanted a string; keep the list),
53+
`license`. Validate after edits:
54+
```bash
55+
GITHUB_TOKEN="$(gh auth token)" ~/go/bin/zsp publish --check zapstore.yaml
56+
```
57+
58+
### Publish
59+
60+
```bash
61+
cd android_dashboard
62+
63+
# 1. Place the CI-built APK where release_source points
64+
TAG=android-vX.Y.Z
65+
gh release download "$TAG" --repo adrienlacombe/sandboxed.sh \
66+
--pattern "sandboxed-dashboard-${TAG}.apk" --dir /tmp/zs-$TAG
67+
mkdir -p app/build/outputs/apk/release
68+
cp /tmp/zs-$TAG/*.apk app/build/outputs/apk/release/app-release.apk
69+
shasum -a 256 app/build/outputs/apk/release/app-release.apk
70+
71+
# 2. Publish (maintainer approves sign requests in nsec.app as they appear)
72+
SIGN_WITH="bunker://7ebbce1843a17cd778a5e169e3d2f679f5ac7b5125d1c43d265e190f7b27538c?relay=wss://relay.nsec.app" \
73+
GITHUB_TOKEN="$(gh auth token)" \
74+
~/go/bin/zsp publish -q --skip-preview --skip-certificate-linking zapstore.yaml
75+
```
76+
77+
zsp flag behavior, learned the hard way:
78+
79+
- `-q` is **required when not on an interactive TTY** (agents, CI): the final
80+
"Ready to Publish" confirmation otherwise dies with
81+
`could not open a new TTY`. `-q` also suppresses ALL output — exit code 0 is
82+
your only success signal, so always verify on the relay afterwards.
83+
- If the version already exists on the relay, zsp stops with
84+
`Asset sh.sandboxed.dashboard@X.Y.Z already exists` (with `-q` it may exit 0
85+
**silently without publishing**). Add `--overwrite-release` to republish.
86+
- The release `d` tag becomes `sh.sandboxed.dashboard@X.Y.Z` (the APK's
87+
versionName, no `android-v` prefix). Keep it that way — it's the existing
88+
convention for all prior releases.
89+
90+
### Verify
91+
92+
```bash
93+
nak req -k 32267 -k 30063 -k 3063 \
94+
-a 7ebbce1843a17cd778a5e169e3d2f679f5ac7b5125d1c43d265e190f7b27538c \
95+
wss://relay.zapstore.dev | jq -c '{kind, id: .id[0:8],
96+
d: ([.tags[]|select(.[0]=="d")|.[1]][0]),
97+
v: ([.tags[]|select(.[0]=="version")|.[1]][0])}'
98+
```
99+
100+
Check: the newest 30063 has `d=sh.sandboxed.dashboard@X.Y.Z`, its `e` tag
101+
equals the newest 3063's id, the 3063 carries `apk_certificate_hash`
102+
`a98fa7bc…` (the release keystore cert; recompute with
103+
`apksigner verify --print-certs`), and the blob resolves:
104+
`curl -I https://cdn.zapstore.dev/<apk-sha256>` → 200.
105+
Listing: https://zapstore.dev/apps/sh.sandboxed.dashboard
106+
107+
### Cleaning up a bad publish
108+
109+
The relay accepts kind-5 deletions. To remove stray events (e.g. a duplicate
110+
release under a wrong `d` tag), sign a kind 5 via the bunker with `e` tags for
111+
each event id, an `a` tag for the addressable release
112+
(`30063:<pubkey>:sh.sandboxed.dashboard@<bad-version>`), and `k` tags, then
113+
publish it to `wss://relay.zapstore.dev` with nak. Replaceable events also
114+
won't overwrite on **equal** `created_at` — bump the timestamp when
115+
republishing an addressable event.
116+
117+
## Appendix — why not the Dart zapstore CLI
118+
119+
Tried on 2026-06-12 (v1.5.0) before discovering zsp handles everything; kept
120+
here so nobody repeats it. The Dart CLI 0.2.4 (last version with `publish`;
121+
the zapstore-cli repo's master is a Go rewrite without it) fails four ways:
122+
123+
1. Its uploads to cdn.zapstore.dev surface as "connection reset by peer"
124+
(the server answers 401 early — its whitelist rejects this client's upload
125+
flow; zsp's works).
126+
2. It treats Blossom `201 Created` as an error (expects 200).
127+
3. The relay requires `NEW_FORMAT=1` (undocumented env toggle) — old-format
128+
kinds (1063) are rejected.
129+
4. It emits `apk_signature_hash` where the relay demands `apk_certificate_hash`,
130+
so kind 3063 is always rejected; fixing it requires manual event surgery
131+
with jq + nak.
132+
133+
Also NIP-46 trivia that applies to any nak-based signing: nak uses a random
134+
client key per run unless `NOSTR_CLIENT_KEY` is set, which burns a bunker
135+
URL's one-time `secret=` on the first run (`already connected` forever after).
136+
A persistent key lives at `~/.config/zapstore/client-key`.
137+
138+
## Security notes
139+
140+
- Never commit or paste bunker URLs containing a `secret=` parameter, any
141+
Nostr private key, or the contents of `keys/release-secrets.env`.
142+
- All signing goes through NIP-46 (nsec.app); the identity key never leaves
143+
the maintainer's signer. Don't ask for or handle nsecs directly.

android_dashboard/README.md

Lines changed: 4 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -264,76 +264,13 @@ The release `signingConfig` only kicks in if `RELEASE_KEYSTORE` is set; without
264264

265265
## Release to Zapstore
266266

267-
Zapstore metadata lives in `zapstore.yaml`. The published app page is:
267+
See **[PUBLISHING.md](PUBLISHING.md)** for the full runbook: GitHub release via
268+
the `android-vX.Y.Z` tag, then `zsp publish` with NIP-46 bunker signing —
269+
including every known failure mode. Zapstore metadata lives in `zapstore.yaml`.
270+
The published app page is:
268271

269272
https://zapstore.dev/apps/sh.sandboxed.dashboard
270273

271-
### Prerequisites
272-
273-
- `~/go/bin/zsp` is installed.
274-
- The release APK exists at `app/build/outputs/apk/release/app-release.apk`.
275-
- The zsp bunker pairing from Oubli is present locally. The paired bunker pubkey is:
276-
`7ebbce1843a17cd778a5e169e3d2f679f5ac7b5125d1c43d265e190f7b27538c`
277-
278-
zsp stores the local client key for that bunker under the user config directory
279-
(`~/Library/Application Support/zsp/bunker-keys/` on macOS). Do not commit bunker
280-
URLs that include a `secret=` parameter or any Nostr private key.
281-
282-
### Publish
283-
284-
Build the signed release APK first, or download the APK from the GitHub release
285-
you want to publish.
286-
287-
```bash
288-
cd android_dashboard
289-
source keys/release-secrets.env
290-
./gradlew :app:assembleRelease
291-
```
292-
293-
To publish an APK that was already built by GitHub Actions:
294-
295-
```bash
296-
TAG=v1.3.0
297-
rm -rf "/tmp/sandboxed-zapstore-${TAG}"
298-
mkdir -p "/tmp/sandboxed-zapstore-${TAG}"
299-
gh release download "${TAG}" \
300-
--repo adrienlacombe/sandboxed.sh \
301-
--pattern "sandboxed-dashboard-${TAG}.apk" \
302-
--dir "/tmp/sandboxed-zapstore-${TAG}"
303-
304-
mkdir -p app/build/outputs/apk/release
305-
cp "/tmp/sandboxed-zapstore-${TAG}/sandboxed-dashboard-${TAG}.apk" \
306-
app/build/outputs/apk/release/app-release.apk
307-
shasum -a 256 app/build/outputs/apk/release/app-release.apk
308-
```
309-
310-
Validate that zsp can read the APK and config:
311-
312-
```bash
313-
GITHUB_TOKEN="$(gh auth token)" ~/go/bin/zsp publish --check zapstore.yaml
314-
```
315-
316-
Publish with the same bunker signer used by Oubli:
317-
318-
```bash
319-
SIGN_WITH="bunker://7ebbce1843a17cd778a5e169e3d2f679f5ac7b5125d1c43d265e190f7b27538c?relay=wss://relay.nsec.app" \
320-
GITHUB_TOKEN="$(gh auth token)" \
321-
~/go/bin/zsp publish -q --skip-preview --skip-certificate-linking zapstore.yaml
322-
```
323-
324-
Approve the signing requests in the remote signer if prompted. A successful run
325-
ends with:
326-
327-
```text
328-
Published sh.sandboxed.dashboard <version> to wss://relay.zapstore.dev
329-
```
330-
331-
If you need to republish the same version after changing metadata or assets, add
332-
`--overwrite-release`.
333-
334-
Record the published APK SHA-256 in release notes or deployment notes after
335-
publishing.
336-
337274
### Lint
338275

339276
```bash

android_dashboard/zapstore.yaml

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
identifier: sh.sandboxed.dashboard
1+
release_source: ./app/build/outputs/apk/release/app-release.apk
2+
repository: https://github.com/adrienlacombe/sandboxed.sh
3+
website: https://sandboxed.sh
4+
icon: ./metadata/icon.png
5+
26
name: Sandboxed
37
summary: Mobile mission control for self-hosted AI coding agents
48
description: |
59
Sandboxed is a mobile dashboard for self-hosted AI coding agent infrastructure.
610
It connects to Sandboxed.sh servers to monitor missions, control workspaces,
711
stream terminals, browse files, review runs, and handle FIDO approvals.
8-
repository: https://github.com/adrienlacombe/sandboxed.sh
9-
homepage: https://sandboxed.sh
10-
icon: ./metadata/icon.png
11-
tags: ai agents coding self-hosted developer-tools
12+
13+
tags:
14+
- ai
15+
- agents
16+
- coding
17+
- self-hosted
18+
- developer-tools
19+
1220
license: MIT
13-
blossom_server: https://nostr.download
14-
assets:
15-
- sandboxed-dashboard-android-v.*\.apk

0 commit comments

Comments
 (0)