build: releases fetch their signing keys from Proton Pass into memory, never onto the disk

tool/with_signing_keys.sh downloads the app's Android keystore and the
F-Droid index's from the vault Infra · Signing into a folder under
$XDG_RUNTIME_DIR, runs tool/release.dart with their paths, and removes
the folder however the run ends. The passwords are asked for as ever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-30 16:54:57 +01:00
commit cd3b88c174
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
4 changed files with 115 additions and 1 deletions

View file

@ -82,7 +82,7 @@ Checklist after doing changes, in this order:
- Check `serverpod` MCP `tail_server_logs` and `tail_flutter_logs` for any issues.
- **Melos** (decided 2026-10-19, ADR 0015) runs scripts across the workspace's packages: a dev dependency of the root (`dart run melos …`, its version in the lock), its scripts under `melos:` in the root `pubspec.yaml`: `analyze`; `test:dart` (the packages without Flutter, one at a time, as each server's tests start a PostgreSQL of their own on the same port); `test:flutter` (`flutter test -x requires-server`, one package at a time); `generate` (Serverpod's code generation in every package with a `config/generator.yaml`, the CLI on the PATH); `check` (`tool/check.dart`). Every script takes Melos' filters: `dart run melos run test:flutter --scope=halkyon_app`, or `--diff=origin/main --include-dependents` for what changed and what depends on it; `dart run melos list` lists the packages. The root overrides `cli_util` to 0.5.2 for it: Learn's `flutter_launcher_icons` (0.14.4, its latest) asks for ^0.4.1 and uses only the logger, which 0.5 keeps; drop the override once that package allows 0.5.
- **Tools are Dart where they hold logic** (decided 2026-10-19): conditions, parsing, parallel work, anything worth a test, as `tool/check.dart` (its file selection in `tool/src/check_files.dart`, tested in the root's `test/`). **Bash for glue**, short and linear (`tool/pre-push`, `tool/check_signatures.sh`, `tool/workspace_only.sh`, which narrows the root pubspec's workspace to the packages an image builds), **and for everything run on the production host**, which has no Dart SDK and gets none (`deploy/redeploy.sh`, `deploy/backup.sh`). **Python only where a library exists only there** (the apps' icons with librsvg and Pillow, `tool/app_icons.py`, its Anki fixtures with Anki itself); Learn's data scripts and `packages/halkyon_app/tool/subdivision_names.py` need nothing but Python's standard library and may move to Dart when they next change. **Releases come from `tool/release.dart`** (decided 2026-10-19, ADR 0015), shared by the apps, each configured in Dart beside it (`apps/<id>/release.dart`, typed and commented, registered in `tool/src/release/apps.dart`): `dart run tool/release.dart learn [major | minor | patch] [--dry-run]`, on a developer's laptop, never in CI. Tags are per app (`learn-v0.19.0`; an app's earlier tags, Learn's `v…`, count as its history), and only commits touching the app decide its version: its folder, the shared packages its packages use by path (transitively), the root pubspec and lock, its release files (key pins, F-Droid listing, snap recipe) and where its packages were before a move. `--dry-run` reads everything a release would (the version, the commits that count and what each touches, the steps, the notes it would publish), changes nothing, asks for no secret, and lists what a release would refuse. Its pure logic is in `tool/src/release/`, tested in the root's `test/release/`. `tool/fdroid_repo.sh` stays Bash (glue for fdroidserver). Build 21 (`v0.18.0`) was the last release of `tool/release.sh`, the Bash script it replaced, removed since.
- **Tools are Dart where they hold logic** (decided 2026-10-19): conditions, parsing, parallel work, anything worth a test, as `tool/check.dart` (its file selection in `tool/src/check_files.dart`, tested in the root's `test/`). **Bash for glue**, short and linear (`tool/pre-push`, `tool/check_signatures.sh`, `tool/workspace_only.sh`, which narrows the root pubspec's workspace to the packages an image builds), **and for everything run on the production host**, which has no Dart SDK and gets none (`deploy/redeploy.sh`, `deploy/backup.sh`). **Python only where a library exists only there** (the apps' icons with librsvg and Pillow, `tool/app_icons.py`, its Anki fixtures with Anki itself); Learn's data scripts and `packages/halkyon_app/tool/subdivision_names.py` need nothing but Python's standard library and may move to Dart when they next change. **Releases come from `tool/release.dart`** (decided 2026-10-19, ADR 0015), shared by the apps, each configured in Dart beside it (`apps/<id>/release.dart`, typed and commented, registered in `tool/src/release/apps.dart`): `dart run tool/release.dart learn [major | minor | patch] [--dry-run]`, on a developer's laptop, never in CI, through `tool/with_signing_keys.sh learn …` (Bash glue): the keystores are kept in Proton Pass alone (vault *Infra · Signing*, each a note's attachment, the password its hidden field), and it downloads the app's and the F-Droid index's into a folder in memory (`$XDG_RUNTIME_DIR`), hands their paths to the release tool and removes them however the run ends (`test/release/signing_keys_test.dart` holds it to every app released). Tags are per app (`learn-v0.19.0`; an app's earlier tags, Learn's `v…`, count as its history), and only commits touching the app decide its version: its folder, the shared packages its packages use by path (transitively), the root pubspec and lock, its release files (key pins, F-Droid listing, snap recipe) and where its packages were before a move. `--dry-run` reads everything a release would (the version, the commits that count and what each touches, the steps, the notes it would publish), changes nothing, asks for no secret, and lists what a release would refuse. Its pure logic is in `tool/src/release/`, tested in the root's `test/release/`. `tool/fdroid_repo.sh` stays Bash (glue for fdroidserver). Build 21 (`v0.18.0`) was the last release of `tool/release.sh`, the Bash script it replaced, removed since.
## CI and deploys

View file

@ -0,0 +1,41 @@
import 'dart:io';
import 'package:test/test.dart';
import '../../tool/src/release/apps.dart';
/// `tool/with_signing_keys.sh` fetches each app's keys from Proton Pass for a
/// release: it must know every app released, by the item its key is kept in
/// and the variable the release tool reads its path from.
void main() {
// The test runs in the repository's root, as `dart test` does.
final script = File('tool/with_signing_keys.sh').readAsStringSync();
test('every app released has its key fetched, under the variable the '
'release tool reads, from the item named for it', () {
for (final app in releasedApps.values) {
expect(
script,
contains(
' ${app.id}) app_variable=${app.android.keystoreVariable} '
"app_item='${app.name} – Android release keystore' ;;",
),
reason: app.id,
);
}
});
test("the F-Droid index's key comes along for an app listed there", () {
expect(releasedApps.values.where((app) => app.fdroid != null), isNotEmpty);
expect(script, contains('HALKYON_FDROID_KEYSTORE='));
expect(
script,
contains("fdroid_item='Halkyon Learn – F-Droid index keystore'"),
);
});
test('the keys go to a folder in memory, removed however the run ends', () {
expect(script, contains(r'mktemp -d -p "$XDG_RUNTIME_DIR"'));
expect(script, contains("trap 'rm -rf \"\$keys\"' EXIT"));
});
}

View file

@ -1,2 +1,3 @@
# The SHA-256 of the certificate that signs Halkyon Train's Android releases (key alias
# halkyon-train), as keytool prints it. tool/release.dart releases no APK signed otherwise.
38:BD:ED:BA:9B:8D:3F:2B:77:DE:20:E1:4F:65:9E:0B:BE:66:7A:07:6C:0F:56:05:8E:80:95:B2:B4:11:8A:6D

72
tool/with_signing_keys.sh Executable file
View file

@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Runs a release with its signing keys fetched from Proton Pass, where alone they are
# kept (vault "Infra · Signing"): the app's Android keystore and the F-Droid index's,
# each a note's attachment, downloaded into a folder in memory ($XDG_RUNTIME_DIR, a
# tmpfs only the own user reads), handed to the release tool by the variables it reads
# their paths from, and removed however the run ends. The keystores' passwords stay in
# Pass: the release tool asks for them, as ever.
#
# tool/with_signing_keys.sh <app> [release.dart's arguments]
# tool/with_signing_keys.sh train minor --dry-run
#
# Needs pass-cli, logged in (pass-cli login), and jq. Reads of the items take only their
# IDs and their attachments' names, never a field.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
readonly vault='Infra · Signing'
fail() {
echo "✗ $*" >&2
exit 1
}
[ $# -ge 1 ] || fail 'Usage: tool/with_signing_keys.sh <app> [release arguments]'
app="$1"
shift
# Each app released (tool/src/release/apps.dart): the variable its keystore's path goes
# in (its AndroidSigning's keystoreVariable) and the item keeping it.
case "$app" in
learn) app_variable=HALKYON_ANDROID_KEYSTORE app_item='Halkyon Learn – Android release keystore' ;;
train) app_variable=HALKYON_TRAIN_ANDROID_KEYSTORE app_item='Halkyon Train – Android release keystore' ;;
*) fail "No signing key known for $app." ;;
esac
# The F-Droid repository's index key, the same for every app listed there.
readonly fdroid_item='Halkyon Learn – F-Droid index keystore'
for tool in pass-cli jq; do
command -v "$tool" >/dev/null || fail "$tool is missing."
done
[ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ] ||
fail 'No $XDG_RUNTIME_DIR to keep the keys in memory.'
keys=$(mktemp -d -p "$XDG_RUNTIME_DIR")
trap 'rm -rf "$keys"' EXIT
trap 'exit 130' INT TERM HUP
# Downloads the attachment named $2 of the item titled $1 to $3.
fetch() {
local item="$1" name="$2" to="$3" ids
ids=$(pass-cli item view --vault-name "$vault" --item-title "$item" --output json |
jq -r --arg name "$name" \
'[.item.share_id, .item.id, (.attachments[] | select(.content.name == $name) | .id)]
| select(length == 3) | @tsv') ||
fail "Can't read the item \"$item\" in $vault (pass-cli login?)."
[ -n "$ids" ] || fail "The item \"$item\" in $vault has no attachment $name."
local share item_id attachment
IFS=$'\t' read -r share item_id attachment <<<"$ids"
pass-cli item attachment download --share-id "$share" --item-id "$item_id" \
--attachment-id "$attachment" --output "$to" >/dev/null
[ -s "$to" ] || fail "The attachment $name of \"$item\" came empty."
}
echo "▸ Fetching the signing keys from $vault"
fetch "$app_item" android-release.p12 "$keys/android-release.p12"
fetch "$fdroid_item" fdroid-index.p12 "$keys/fdroid-index.p12"
export "$app_variable=$keys/android-release.p12"
export HALKYON_FDROID_KEYSTORE="$keys/fdroid-index.p12"
status=0
dart run tool/release.dart "$app" "$@" || status=$?
exit "$status"