|
| 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. |
0 commit comments