Skip to content

Commit 41a8e54

Browse files
authored
Merge pull request #144 from sledtools/zapstore
ci: add Zapstore publish pipeline with hardened signing secret handling
2 parents ac42e57 + d6cc413 commit 41a8e54

10 files changed

Lines changed: 405 additions & 4 deletions

File tree

.github/workflows/release.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,51 @@ jobs:
145145
files: |
146146
dist/*.apk
147147
dist/SHA256SUMS
148+
149+
publish-zapstore:
150+
if: (github.actor == 'justinmoon' || github.actor == 'futurepaul' || github.actor == 'AnthonyRonning' || github.actor == 'benthecarman') && hashFiles('secrets/zapstore-signing.env.age') != ''
151+
needs: [build, publish]
152+
runs-on: blacksmith-16vcpu-ubuntu-2404
153+
steps:
154+
- uses: actions/checkout@v4
155+
156+
- uses: useblacksmith/stickydisk@v1
157+
with:
158+
key: ${{ github.repository }}-nix-v1-${{ runner.os }}
159+
path: /nix
160+
- name: Fix /nix ownership
161+
run: |
162+
if [ -d /nix ] && [ "$(stat -c %u /nix)" != "$(id -u)" ]; then
163+
sudo chown -R $(id -u):$(id -g) /nix
164+
fi
165+
- uses: nixbuild/nix-quick-install-action@v30
166+
167+
- uses: actions/download-artifact@v4
168+
with:
169+
name: release-apk
170+
path: dist
171+
172+
- name: Validate tag matches VERSION
173+
run: |
174+
set -euo pipefail
175+
expected_tag="v$(./scripts/version-read --name)"
176+
if [ "${GITHUB_REF_NAME}" != "$expected_tag" ]; then
177+
echo "error: tag/version mismatch: ref=${GITHUB_REF_NAME}, expected=${expected_tag}"
178+
exit 1
179+
fi
180+
181+
- name: Publish to Zapstore
182+
env:
183+
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
184+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
185+
run: |
186+
set -euo pipefail
187+
set +x
188+
apk_count="$(find dist -maxdepth 1 -type f -name '*.apk' | wc -l | xargs)"
189+
if [ "$apk_count" -ne 1 ]; then
190+
echo "error: expected exactly one APK artifact in dist/, found $apk_count"
191+
find dist -maxdepth 1 -type f -name '*.apk' -print
192+
exit 1
193+
fi
194+
apk_path="$(find dist -maxdepth 1 -type f -name '*.apk' | head -n 1)"
195+
nix develop .#default -c ./scripts/zapstore-publish "$apk_path" "https://github.com/${GITHUB_REPOSITORY}"

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ worktrees
4040
.pika-cli-test
4141
ios/UITests/env.test
4242
.DS_Store
43+
secrets/zapstore-signing.env
44+
secrets/.age-key*.tmp
45+
secrets/.zapstore-sign-with.*
4346

4447
# Avoid accidentally staging enormous vendored build output.
4548
third_party/openclaw-marmot/target/

docs/release.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ read_when:
88

99
# Release
1010

11-
This repo has two independent release pipelines, both tag-driven:
11+
This repo has three independent release pipelines, all tag-driven:
1212

1313
| Target | Tag pattern | CI workflow | Artifacts |
1414
|--------|------------|-------------|-----------|
1515
| Android APK | `v*` (e.g. `v0.2.2`) | `release.yml` | Signed APK + SHA256SUMS on GitHub Releases |
16+
| Zapstore publish | `v*` (e.g. `v0.2.2`) | `release.yml` (`publish-zapstore` job) | NIP-82 app/release/asset events on Zapstore relays |
1617
| marmotd (OpenClaw extension) | `marmotd-v*` (e.g. `marmotd-v0.3.2`) | `marmotd-release.yml` | Linux + macOS binaries on GitHub Releases, npm package |
1718

1819
**Important:** All release tags must be created from the `master` branch. Tags on
@@ -69,13 +70,23 @@ gh release view v0.3.0
6970

7071
- Commit only encrypted keystore: `android/pika-release.jks.age`.
7172
- Commit only encrypted signing env: `secrets/android-signing.env.age`.
73+
- Commit only encrypted Zapstore signing env: `secrets/zapstore-signing.env.age`.
7274
- Keep plaintext `android/pika-release.jks` out of git.
73-
- Encrypt both artifacts to all required recipients:
75+
- Keep plaintext Zapstore signing env (`secrets/zapstore-signing.env`) out of git.
76+
- Encrypt all encrypted artifacts to all required recipients:
7477
- YubiKey primary: `age1yubikey1q0zhu9e7zrj48zmnpx4fg07c0drt9f57e26uymgxa4h3fczwutzjjp5a6y5`
7578
- YubiKey backup: `age1yubikey1qtdv7spad78v4yhrtrts6tvv5wc80vw6mah6g64m9cr9l3ryxsf2jdx8gs9`
7679
- CI age public key (dedicated release key)
7780
- CI env var required:
78-
- `AGE_SECRET_KEY` (decrypts both encrypted artifacts in CI)
81+
- `AGE_SECRET_KEY` (decrypts all encrypted artifacts in CI)
82+
- Zapstore encrypted env format:
83+
- `ZAPSTORE_SIGN_WITH=nsec1...` (or NIP-46 bunker URL)
84+
- Helper command:
85+
- `just zapstore-encrypt-signing`
86+
- or include in full bootstrap: `PIKA_ZAPSTORE_SIGN_WITH='nsec1...' ./scripts/init-release-secrets`
87+
- Publish helper:
88+
- `./scripts/zapstore-publish <apk-path> [repo-url]`
89+
- used by both `just zapstore-publish` and CI to centralize secret handling
7990
- Optional for local hardware-key decrypt:
8091
- `PIKA_AGE_IDENTITY_FILE` (defaults to `~/configs/yubikeys/keys.txt`)
8192

@@ -88,6 +99,12 @@ Jobs:
8899
1. `check` - validates tag/version match and runs `just pre-merge-pika`
89100
2. `build` - runs `just android-release`, uploads APK + `SHA256SUMS`
90101
3. `publish` - creates GitHub Release with uploaded assets
102+
4. `publish-zapstore` - publishes the built APK artifact to Zapstore relays
103+
104+
`publish-zapstore` is gated on `secrets/zapstore-signing.env.age` existing in
105+
git. It decrypts `ZAPSTORE_SIGN_WITH` via `AGE_SECRET_KEY`, uses centralized
106+
`scripts/zapstore-publish` handling (xtrace disabled, masking enabled, temp-file
107+
cleanup), and passes it to `zsp` only for the publish command.
91108

92109
---
93110

flake.nix

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,28 @@
103103
mainProgram = "cargo-dinghy";
104104
};
105105
};
106+
107+
zsp = pkgs.buildGoModule rec {
108+
pname = "zsp";
109+
version = "0.3.3";
110+
111+
src = pkgs.fetchFromGitHub {
112+
owner = "zapstore";
113+
repo = "zsp";
114+
rev = "v${version}";
115+
hash = "sha256-OiCk+LatiD+W0MR9klEWZ/bx/9QK1+MjO4lKyHSOFn8=";
116+
};
117+
118+
vendorHash = "sha256-INIDPettuY0y4h6NF8ltF9r/AMQx9Each9JVBe9+CGo=";
119+
doCheck = false;
120+
121+
meta = with pkgs.lib; {
122+
description = "CLI tool for publishing Android apps to Nostr relays";
123+
homepage = "https://github.com/zapstore/zsp";
124+
license = licenses.mit;
125+
mainProgram = "zsp";
126+
};
127+
};
106128
in {
107129
devShells.default = pkgs.mkShell {
108130
buildInputs = pkgs.lib.optionals pkgs.stdenv.isDarwin [
@@ -131,6 +153,7 @@
131153
pkgs.age
132154
pkgs.age-plugin-yubikey
133155
pkgs.openssl
156+
zsp
134157
rmp
135158
] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [
136159
pkgs.xcodegen

justfile

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,18 @@ android-release:
297297
cp android/app/build/outputs/apk/release/app-release.apk "dist/pika-${version}-${abis}.apk"; \
298298
echo "ok: built dist/pika-${version}-${abis}.apk"
299299

300+
# Encrypt Zapstore signing value to `secrets/zapstore-signing.env.age`.
301+
zapstore-encrypt-signing:
302+
./scripts/encrypt-zapstore-signing
303+
304+
# Check Zapstore publish inputs for a local APK without publishing events.
305+
zapstore-check APK:
306+
zsp publish --check "{{APK}}" -r https://github.com/sledtools/pika
307+
308+
# Publish a local APK artifact to Zapstore.
309+
zapstore-publish APK:
310+
./scripts/zapstore-publish "{{APK}}" https://github.com/sledtools/pika
311+
300312
# Build Android debug APK.
301313
android-assemble: gen-kotlin android-rust android-local-properties
302314
cd android && ./gradlew :app:assembleDebug

scripts/encrypt-zapstore-signing

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
OUTPUT_ENCRYPTED="$ROOT/secrets/zapstore-signing.env.age"
6+
OUTPUT_ENCRYPTED_NEXT="$ROOT/secrets/zapstore-signing.env.age.next"
7+
CI_KEY_TMP="$ROOT/secrets/.age-key.ci.tmp"
8+
9+
# Defaults from ~/configs/secrets/secrets.nix; can be overridden via env.
10+
YUBIKEY_PRIMARY="${PIKA_YUBIKEY_PRIMARY_RECIPIENT:-age1yubikey1q0zhu9e7zrj48zmnpx4fg07c0drt9f57e26uymgxa4h3fczwutzjjp5a6y5}"
11+
YUBIKEY_BACKUP="${PIKA_YUBIKEY_BACKUP_RECIPIENT:-age1yubikey1qtdv7spad78v4yhrtrts6tvv5wc80vw6mah6g64m9cr9l3ryxsf2jdx8gs9}"
12+
CI_RECIPIENT="${PIKA_CI_AGE_RECIPIENT:-}"
13+
14+
usage() {
15+
cat <<'USAGE'
16+
usage: ./scripts/encrypt-zapstore-signing
17+
18+
Encrypts `secrets/zapstore-signing.env.age` with:
19+
- YubiKey primary recipient
20+
- YubiKey backup recipient
21+
- CI recipient (from PIKA_CI_AGE_RECIPIENT, AGE_SECRET_KEY, `server` in configs, or prompt)
22+
23+
Input:
24+
- `PIKA_ZAPSTORE_SIGN_WITH` (recommended)
25+
- or interactive prompt
26+
27+
Environment overrides:
28+
PIKA_ZAPSTORE_SIGN_WITH
29+
PIKA_CI_AGE_RECIPIENT
30+
PIKA_YUBIKEY_PRIMARY_RECIPIENT
31+
PIKA_YUBIKEY_BACKUP_RECIPIENT
32+
USAGE
33+
}
34+
35+
need_cmd() {
36+
if ! command -v "$1" >/dev/null 2>&1; then
37+
echo "error: missing command: $1" >&2
38+
exit 1
39+
fi
40+
}
41+
42+
load_recipients_from_configs() {
43+
local cfg="$HOME/configs/secrets/secrets.nix"
44+
local candidate=""
45+
if [ ! -f "$cfg" ]; then
46+
return 0
47+
fi
48+
49+
candidate="$(grep -E 'yubikey_primary = "age1' "$cfg" | head -n 1 | sed -E 's/.*"(age1[^"]+)".*/\1/')"
50+
if [ -z "${PIKA_YUBIKEY_PRIMARY_RECIPIENT:-}" ] && [ -n "$candidate" ]; then
51+
YUBIKEY_PRIMARY="$candidate"
52+
fi
53+
54+
candidate="$(grep -E 'yubikey_backup = "age1' "$cfg" | head -n 1 | sed -E 's/.*"(age1[^"]+)".*/\1/')"
55+
if [ -z "${PIKA_YUBIKEY_BACKUP_RECIPIENT:-}" ] && [ -n "$candidate" ]; then
56+
YUBIKEY_BACKUP="$candidate"
57+
fi
58+
59+
if [ -z "${PIKA_CI_AGE_RECIPIENT:-}" ] && [ -z "$CI_RECIPIENT" ]; then
60+
candidate="$(grep -E 'server = "age1' "$cfg" | head -n 1 | sed -E 's/.*"(age1[^"]+)".*/\1/')"
61+
if [ -n "$candidate" ]; then
62+
CI_RECIPIENT="$candidate"
63+
fi
64+
fi
65+
}
66+
67+
cleanup() {
68+
rm -f "$CI_KEY_TMP" "$OUTPUT_ENCRYPTED_NEXT"
69+
}
70+
trap cleanup EXIT INT TERM
71+
72+
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
73+
usage
74+
exit 0
75+
fi
76+
if [ "$#" -ne 0 ]; then
77+
usage >&2
78+
exit 2
79+
fi
80+
81+
need_cmd age
82+
need_cmd age-keygen
83+
need_cmd grep
84+
need_cmd sed
85+
86+
load_recipients_from_configs
87+
88+
if [ -z "$CI_RECIPIENT" ] && [ -n "${AGE_SECRET_KEY:-}" ]; then
89+
umask 077
90+
printf '%s\n' "$AGE_SECRET_KEY" >"$CI_KEY_TMP"
91+
CI_RECIPIENT="$(age-keygen -y "$CI_KEY_TMP")"
92+
fi
93+
94+
if [ -z "$CI_RECIPIENT" ]; then
95+
if [ -t 0 ]; then
96+
read -r -p "Enter CI age recipient (age1...): " CI_RECIPIENT
97+
else
98+
echo "error: set PIKA_CI_AGE_RECIPIENT, AGE_SECRET_KEY, or configure server recipient in ~/configs/secrets/secrets.nix" >&2
99+
exit 1
100+
fi
101+
fi
102+
103+
if ! printf '%s\n' "$CI_RECIPIENT" | grep -Eq '^age1[023456789acdefghjklmnpqrstuvwxyz]+$'; then
104+
echo "error: invalid CI recipient format (expected age1...)" >&2
105+
exit 1
106+
fi
107+
108+
sign_with="${PIKA_ZAPSTORE_SIGN_WITH:-}"
109+
if [ -z "$sign_with" ]; then
110+
if [ ! -t 0 ]; then
111+
echo "error: set PIKA_ZAPSTORE_SIGN_WITH when running non-interactively" >&2
112+
exit 1
113+
fi
114+
read -r -s -p "Enter Zapstore SIGN_WITH (nsec or bunker URL): " sign_with
115+
printf '\n'
116+
fi
117+
118+
if [ -z "$sign_with" ]; then
119+
echo "error: missing Zapstore SIGN_WITH value" >&2
120+
exit 1
121+
fi
122+
123+
mkdir -p "$ROOT/secrets"
124+
umask 077
125+
126+
{
127+
printf 'ZAPSTORE_SIGN_WITH=%s\n' "$sign_with"
128+
} | age -e \
129+
-r "$YUBIKEY_PRIMARY" \
130+
-r "$YUBIKEY_BACKUP" \
131+
-r "$CI_RECIPIENT" \
132+
-o "$OUTPUT_ENCRYPTED_NEXT"
133+
134+
chmod 600 "$OUTPUT_ENCRYPTED_NEXT"
135+
mv "$OUTPUT_ENCRYPTED_NEXT" "$OUTPUT_ENCRYPTED"
136+
137+
unset sign_with
138+
139+
echo "ok: wrote $OUTPUT_ENCRYPTED"
140+
echo "ok: recipients:"
141+
echo " - $YUBIKEY_PRIMARY"
142+
echo " - $YUBIKEY_BACKUP"
143+
echo " - $CI_RECIPIENT (CI)"

scripts/init-release-secrets

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
55

66
KEYSTORE_ENCRYPTED="$ROOT/android/pika-release.jks.age"
77
PASSWORD_ENCRYPTED="$ROOT/secrets/android-signing.env.age"
8+
ZAPSTORE_SIGNING_ENCRYPTED="$ROOT/secrets/zapstore-signing.env.age"
89
KEYSTORE_PLAINTEXT="$ROOT/android/pika-release.jks"
910
KEYSTORE_TEST="$ROOT/android/pika-release.test.jks"
1011
KEYSTORE_ENCRYPTED_NEXT="$ROOT/android/pika-release.jks.age.next"
1112
PASSWORD_ENCRYPTED_NEXT="$ROOT/secrets/android-signing.env.age.next"
13+
ZAPSTORE_SIGNING_ENCRYPTED_NEXT="$ROOT/secrets/zapstore-signing.env.age.next"
1214
CI_KEY_TMP="$ROOT/android/.pika-ci-age.key.tmp"
1315
YUBIKEY_IDENTITY_FILE="${PIKA_AGE_IDENTITY_FILE:-$HOME/configs/yubikeys/yubikey-primary.txt}"
1416

@@ -43,6 +45,7 @@ Environment overrides:
4345
PIKA_YUBIKEY_PRIMARY_RECIPIENT
4446
PIKA_YUBIKEY_BACKUP_RECIPIENT
4547
PIKA_KEYSTORE_PASSWORD
48+
PIKA_ZAPSTORE_SIGN_WITH
4649
EOF
4750
}
4851

@@ -72,7 +75,7 @@ load_recipients_from_configs() {
7275
}
7376

7477
cleanup() {
75-
rm -f "$KEYSTORE_PLAINTEXT" "$KEYSTORE_TEST" "$CI_KEY_TMP" "$KEYSTORE_ENCRYPTED_NEXT" "$PASSWORD_ENCRYPTED_NEXT"
78+
rm -f "$KEYSTORE_PLAINTEXT" "$KEYSTORE_TEST" "$CI_KEY_TMP" "$KEYSTORE_ENCRYPTED_NEXT" "$PASSWORD_ENCRYPTED_NEXT" "$ZAPSTORE_SIGNING_ENCRYPTED_NEXT"
7679
}
7780
trap cleanup EXIT INT TERM
7881

@@ -168,6 +171,20 @@ rm -f "$PASSWORD_ENCRYPTED_NEXT"
168171
chmod 600 "$PASSWORD_ENCRYPTED_NEXT"
169172
mv "$PASSWORD_ENCRYPTED_NEXT" "$PASSWORD_ENCRYPTED"
170173

174+
if [ -n "${PIKA_ZAPSTORE_SIGN_WITH:-}" ]; then
175+
echo "Encrypting Zapstore signing env to recipients..."
176+
rm -f "$ZAPSTORE_SIGNING_ENCRYPTED_NEXT"
177+
{
178+
printf 'ZAPSTORE_SIGN_WITH=%s\n' "$PIKA_ZAPSTORE_SIGN_WITH"
179+
} | age -e \
180+
-r "$YUBIKEY_PRIMARY" \
181+
-r "$YUBIKEY_BACKUP" \
182+
-r "$ci_recipient" \
183+
-o "$ZAPSTORE_SIGNING_ENCRYPTED_NEXT"
184+
chmod 600 "$ZAPSTORE_SIGNING_ENCRYPTED_NEXT"
185+
mv "$ZAPSTORE_SIGNING_ENCRYPTED_NEXT" "$ZAPSTORE_SIGNING_ENCRYPTED"
186+
fi
187+
171188
echo "Testing YubiKey decryption with $YUBIKEY_IDENTITY_FILE (touch/PIN may be required)..."
172189
rm -f "$KEYSTORE_TEST"
173190
age -d -i "$YUBIKEY_IDENTITY_FILE" -o "$KEYSTORE_TEST" "$KEYSTORE_ENCRYPTED"
@@ -195,6 +212,11 @@ unset ci_secret_key
195212

196213
echo "ok: wrote $KEYSTORE_ENCRYPTED"
197214
echo "ok: wrote $PASSWORD_ENCRYPTED"
215+
if [ -n "${PIKA_ZAPSTORE_SIGN_WITH:-}" ]; then
216+
echo "ok: wrote $ZAPSTORE_SIGNING_ENCRYPTED"
217+
else
218+
echo "note: skipped $ZAPSTORE_SIGNING_ENCRYPTED (set PIKA_ZAPSTORE_SIGN_WITH to include it)"
219+
fi
198220
echo "ok: recipients:"
199221
echo " - $YUBIKEY_PRIMARY"
200222
echo " - $YUBIKEY_BACKUP"

0 commit comments

Comments
 (0)