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