Skip to content

Commit 20ad56d

Browse files
feat: added auto updater
1 parent 777a28a commit 20ad56d

18 files changed

Lines changed: 2228 additions & 53 deletions

File tree

.github/workflows/release.yml

Lines changed: 195 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,31 @@ permissions:
3030
# well-scoped permissions block.
3131
packages: write
3232

33+
# Auto-updater public key, embedded into every release binary at compile
34+
# time via `option_env!("MHRV_UPDATE_PUBKEY")` (see src/update_apply.rs).
35+
# Read from the repo variable `MINISIGN_PUBLIC_KEY` — the bare base64 line
36+
# from a `minisign -G` .pub file (the one *after* the `untrusted comment`).
37+
#
38+
# This env var is populated only when `MINISIGN_SIGNING_ENABLED == 'true'`.
39+
# Empty/whitespace values are treated as "unset" by `src/update_apply.rs`:
40+
# binaries log a runtime warning + apply updates without a sig check. When
41+
# it IS non-empty, desktop and Android update flows refuse to apply an asset
42+
# that doesn't have a matching `.minisig` next to it on the release page.
43+
#
44+
# To enable signed updates end-to-end:
45+
# 1. Generate a keypair (one-time, offline):
46+
# rsign generate -p mhrv-update.pub -s mhrv-update.key
47+
# 2. `gh variable set MINISIGN_PUBLIC_KEY --body "$(tail -1 mhrv-update.pub)"`
48+
# 3. `gh secret set MINISIGN_SECRET_KEY < mhrv-update.key`
49+
# 4. Optional, for passphrased keys:
50+
# `gh secret set MINISIGN_KEY_PASSWORD --body 'your-passphrase'`
51+
# 5. `gh variable set MINISIGN_SIGNING_ENABLED --body true`
52+
#
53+
# Once those are set, the next tag push produces signed artifacts
54+
# and the freshly-built binaries enforce verification on the next update.
55+
env:
56+
MHRV_UPDATE_PUBKEY: ${{ vars.MINISIGN_SIGNING_ENABLED == 'true' && vars.MINISIGN_PUBLIC_KEY || '' }}
57+
3358
# Runner strategy:
3459
# - Linux + Android + mipsel: self-hosted (mhrv-hetzner-*, Hetzner
3560
# 8-core / 31 GB Ubuntu 24.04 box with
@@ -248,6 +273,7 @@ jobs:
248273
if: matrix.target == 'x86_64-unknown-linux-musl'
249274
run: |
250275
docker run --rm -v "$PWD":/src -w /src \
276+
-e MHRV_UPDATE_PUBKEY \
251277
messense/rust-musl-cross:x86_64-musl \
252278
cargo build --release --target x86_64-unknown-linux-musl --bin mhrv-rs
253279
sudo chown -R "$(id -u):$(id -g)" target
@@ -256,6 +282,7 @@ jobs:
256282
if: matrix.target == 'aarch64-unknown-linux-musl'
257283
run: |
258284
docker run --rm -v "$PWD":/src -w /src \
285+
-e MHRV_UPDATE_PUBKEY \
259286
messense/rust-musl-cross:aarch64-musl \
260287
cargo build --release --target aarch64-unknown-linux-musl --bin mhrv-rs
261288
sudo chown -R "$(id -u):$(id -g)" target
@@ -293,6 +320,7 @@ jobs:
293320
trap 'sudo chown -R "$(id -u):$(id -g)" target 2>/dev/null || true' EXIT
294321
docker run --rm -v "$PWD":/src -w /src \
295322
-e RUSTFLAGS='-C target-feature=+soft-float' \
323+
-e MHRV_UPDATE_PUBKEY \
296324
messense/rust-musl-cross:mipsel-musl \
297325
bash -c '
298326
set -eux
@@ -514,6 +542,136 @@ jobs:
514542
path: dist/*.apk
515543
if-no-files-found: error
516544

545+
# Sign every release artifact with minisign — see src/update_apply.rs
546+
# for the threat model. Produces `<asset>.minisig` files alongside the
547+
# build artifacts so the auto-updater can verify provenance before
548+
# swapping the running binary.
549+
#
550+
# Gracefully no-ops when `vars.MINISIGN_SIGNING_ENABLED != 'true'` so
551+
# the workflow keeps shipping releases until the maintainer sets up
552+
# the keypair (see workflow-level `env` block at the top for the
553+
# one-time setup commands).
554+
#
555+
# Tool: rsign2 (Frank Denis, Rust port of minisign). Produces signatures
556+
# binary-compatible with the OG `minisign` and verifiable by the
557+
# `minisign-verify` crate the updater uses. Picked over apt-installing
558+
# `minisign` because rsign2 cross-installs the same way on every runner
559+
# we have (Linux self-hosted, Linux GH-hosted) via `cargo install`.
560+
sign:
561+
needs: [build, android]
562+
if: ${{ vars.MINISIGN_SIGNING_ENABLED == 'true' }}
563+
runs-on: ubuntu-latest
564+
env:
565+
RSIGN2_VERSION: 0.6.5
566+
permissions:
567+
contents: read
568+
steps:
569+
- uses: actions/checkout@v4
570+
571+
- uses: dtolnay/rust-toolchain@stable
572+
573+
# Cache the pinned `cargo install rsign2` output so we're not
574+
# rebuilding it from scratch every release. The key includes the
575+
# rsign2 version so tool upgrades invalidate the cache deliberately.
576+
- uses: Swatinem/rust-cache@v2
577+
with:
578+
key: rsign2-${{ env.RSIGN2_VERSION }}-stable
579+
cache-bin: "false"
580+
581+
- name: Install rsign2
582+
run: |
583+
# `--version` pins the signing tool itself; `--locked` uses that
584+
# crate release's Cargo.lock so transitive bumps are deliberate too.
585+
cargo install --quiet --locked --version "${RSIGN2_VERSION}" rsign2
586+
587+
- name: Download all build artifacts
588+
env:
589+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
590+
run: |
591+
mkdir -p dist
592+
# Same retry pattern as the `release` job — the artifacts API
593+
# has been intermittently 5-retries-exhausted on this workflow,
594+
# `gh run download` against the current run ID is more reliable.
595+
for attempt in 1 2 3; do
596+
if gh run download "${GITHUB_RUN_ID}" --dir dist --repo "${GITHUB_REPOSITORY}"; then
597+
echo "downloaded all artifacts on attempt $attempt"
598+
# `gh run download` puts each artifact in its own subdir;
599+
# flatten so the sign loop sees `dist/<file>` directly.
600+
find dist -type f -mindepth 2 -exec mv -f {} dist/ \;
601+
find dist -type d -empty -delete
602+
ls -la dist/
603+
exit 0
604+
fi
605+
echo "download attempt $attempt failed; retrying in 30s..."
606+
sleep 30
607+
done
608+
echo "::error::failed to download artifacts after 3 attempts"
609+
exit 1
610+
611+
- name: Sign artifacts
612+
env:
613+
# Whole secret-key file content (multi-line — the `untrusted
614+
# comment` line plus the base64 key line). Pass it via env to
615+
# avoid quoting issues inside a heredoc.
616+
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
617+
# rsign2 reads the key passphrase from RSIGN_PASSWORD when set
618+
# (so we can sign non-interactively in CI). For passwordless
619+
# keys (generated with `rsign generate -p ... -s ... -W`), an
620+
# empty string here is correct.
621+
RSIGN_PASSWORD: ${{ secrets.MINISIGN_KEY_PASSWORD }}
622+
run: |
623+
set -euo pipefail
624+
if [ -z "${MHRV_UPDATE_PUBKEY:-}" ]; then
625+
echo "::error::MINISIGN_SIGNING_ENABLED is true but MINISIGN_PUBLIC_KEY repo variable is empty"
626+
exit 1
627+
fi
628+
if [ -z "${MINISIGN_SECRET_KEY:-}" ]; then
629+
echo "::error::MINISIGN_SIGNING_ENABLED is true but MINISIGN_SECRET_KEY secret is empty"
630+
exit 1
631+
fi
632+
# Write the key to a temp file. Use a strict umask so a stray
633+
# `set -x` later doesn't expose it to other steps' logs (the
634+
# file path is fine; the contents aren't).
635+
umask 077
636+
KEY_FILE="$(mktemp -t mhrv-sign-XXXXXX.key)"
637+
# `printf` rather than `echo`: preserves the secret body without
638+
# shell-specific escapes, while ensuring the file ends with a
639+
# newline for tools that expect line-oriented minisign keys.
640+
printf '%s\n' "${MINISIGN_SECRET_KEY}" > "${KEY_FILE}"
641+
642+
# Trap to wipe the key on any exit (success, failure, signal).
643+
trap 'rm -f "${KEY_FILE}"' EXIT
644+
645+
shopt -s nullglob
646+
signed=0
647+
for f in dist/*.tar.gz dist/*.zip dist/*.apk; do
648+
[ -f "$f" ] || continue
649+
echo "::group::sign $(basename "$f")"
650+
# `-W` = no password prompt (read from RSIGN_PASSWORD env)
651+
# `-x <out>` = write the signature to a specific path so it
652+
# lands as `<asset>.minisig` instead of rsign's default
653+
# next-to-binary location.
654+
rsign sign \
655+
-W \
656+
-s "${KEY_FILE}" \
657+
-x "${f}.minisig" \
658+
"$f"
659+
ls -la "${f}.minisig"
660+
echo "::endgroup::"
661+
signed=$((signed + 1))
662+
done
663+
echo "signed ${signed} artifacts"
664+
if [ "${signed}" -eq 0 ]; then
665+
echo "::warning::no artifacts matched dist/*.{tar.gz,zip,apk}"
666+
fi
667+
668+
- name: Upload signatures
669+
uses: actions/upload-artifact@v4
670+
with:
671+
name: minisign-signatures
672+
path: dist/*.minisig
673+
if-no-files-found: error
674+
517675
# Build + publish the tunnel-node Docker image to GHCR. Issue: every
518676
# full-mode user has to set up tunnel-node on a VPS, and "rustup +
519677
# cargo build --release" on a 1GB VPS is non-trivial — fails on memory,
@@ -589,7 +747,17 @@ jobs:
589747
# off the self-hosted runners avoids contention with Linux build jobs from
590748
# the next tag if two releases overlap.
591749
release:
592-
needs: [build, android]
750+
needs: [build, android, sign]
751+
# When `sign` is skipped (signing not yet enabled in repo vars) we
752+
# still want the release to proceed with unsigned artifacts. GH
753+
# Actions' default behaviour skips dependent jobs whenever ANY needed
754+
# job is skipped — this `if:` overrides that so a skipped `sign`
755+
# doesn't block release, but a `sign` *failure* still does.
756+
if: |
757+
always()
758+
&& needs.build.result == 'success'
759+
&& needs.android.result == 'success'
760+
&& (needs.sign.result == 'success' || needs.sign.result == 'skipped')
593761
runs-on: ubuntu-latest
594762
permissions:
595763
contents: write
@@ -706,7 +874,16 @@ jobs:
706874
# `https://github.com/.../releases/tag/v...` for users who can reach
707875
# that URL — this in-repo folder is the fallback for users who can't.
708876
commit-releases:
709-
needs: [build, android, release]
877+
needs: [build, android, sign, release]
878+
# Same skipped-sign escape hatch as the `release` job: `always()` keeps
879+
# this job evaluable when `sign` is skipped, while the explicit success
880+
# checks still block cancellations/failures from build/android/release.
881+
if: |
882+
always()
883+
&& needs.build.result == 'success'
884+
&& needs.android.result == 'success'
885+
&& needs.release.result == 'success'
886+
&& (needs.sign.result == 'success' || needs.sign.result == 'skipped')
710887
runs-on: ubuntu-latest
711888
permissions:
712889
contents: write
@@ -748,7 +925,8 @@ jobs:
748925
--dir artifacts \
749926
--pattern '*.tar.gz' \
750927
--pattern '*.zip' \
751-
--pattern '*.apk'
928+
--pattern '*.apk' \
929+
--pattern '*.minisig'
752930
echo "--- artifacts/ contents ---"
753931
ls -la artifacts/
754932
@@ -760,12 +938,12 @@ jobs:
760938
761939
mkdir -p releases
762940
763-
# Wipe old binary artifacts (.apk, .tar.gz, .zip) but keep
764-
# README.md and .gitattributes — those are folder-level docs
765-
# that stay constant across versions and shouldn't be
941+
# Wipe old binary artifacts (.apk, .tar.gz, .zip, .minisig) but
942+
# keep README.md and .gitattributes — those are folder-level
943+
# docs that stay constant across versions and shouldn't be
766944
# regenerated on every release.
767945
find releases -maxdepth 1 -type f \
768-
\( -name '*.apk' -o -name '*.tar.gz' -o -name '*.zip' \) \
946+
\( -name '*.apk' -o -name '*.tar.gz' -o -name '*.zip' -o -name '*.minisig' \) \
769947
-delete
770948
771949
# Copy desktop archives. Their names already include the
@@ -785,6 +963,16 @@ jobs:
785963
cp "$f" "releases/$(basename "$f")"
786964
done
787965
966+
# Minisign signatures, when present (signing is opt-in via the
967+
# MINISIGN_SIGNING_ENABLED repo variable). Naming follows the
968+
# `<asset>.minisig` convention the auto-updater expects, so a
969+
# user who hits the in-repo `releases/` fallback path
970+
# (GitHub-Releases-page filtered ISP) gets verified updates too.
971+
for f in artifacts/*.minisig; do
972+
[ -f "$f" ] || continue
973+
cp "$f" "releases/$(basename "$f")"
974+
done
975+
788976
# Update the "Current version" line in releases/README.md
789977
# (both English and Persian copies) and APK filename refs so
790978
# the doc stays accurate. `sed -i` BSD/GNU compatibility is

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
/dist
33
/ca
44
/config.json
5+
/android/.kotlin/
56
.DS_Store
67
/SCR-*.png

0 commit comments

Comments
 (0)