diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..494e166 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,148 @@ +name: release + +# release-please cuts the tag; this builds the sender for each platform, +# attaches the tarballs to the release, and points the Homebrew tap at them. +on: + release: + types: [published] + # Lets a failed or re-run release be rebuilt without cutting a new tag. + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to build (e.g. v2.9.0)" + required: true + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: rust-sender + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Install the Rust toolchain + run: rustup target add ${{ matrix.target }} + + - run: cargo build --release --locked --target ${{ matrix.target }} + + - name: Package the binary + id: package + run: | + TAG="${{ inputs.tag || github.event.release.tag_name }}" + VERSION="${TAG#v}" + NAME="porter-sender_${VERSION}_${{ matrix.target }}.tar.gz" + tar -czf "$NAME" -C "target/${{ matrix.target }}/release" porter-sender + echo "name=$NAME" >> "$GITHUB_OUTPUT" + # Homebrew verifies this; the formula job reads it back from here. + shasum -a 256 "$NAME" | cut -d" " -f1 > "$NAME.sha256" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target }} + path: | + rust-sender/${{ steps.package.outputs.name }} + rust-sender/${{ steps.package.outputs.name }}.sha256 + + - name: Attach to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ inputs.tag || github.event.release.tag_name }}" \ + "${{ steps.package.outputs.name }}" --clobber + + formula: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + + - name: Write the formula + run: | + TAG="${{ inputs.tag || github.event.release.tag_name }}" + VERSION="${TAG#v}" + BASE="https://github.com/TerrorSquad/porter/releases/download/${TAG}" + + sha() { cat "dist/$1/porter-sender_${VERSION}_$1.tar.gz.sha256"; } + + cat > porter-sender.rb < +keyPassword= +keyAlias=porter +storeFile=/absolute/path/to/porter-upload.jks +``` + + + +> **Back up `porter-upload.jks` and its passwords.** Losing the keystore means +> no existing install can ever be upgraded — every user has to uninstall and +> reinstall, losing app data. There is no recovery path. + +## Build + +```sh +mise run flutter-apk # universal APK → build/app/outputs/flutter-apk/ +mise run flutter-apk-split # per-ABI APKs, ~1/3 the size each +``` + +Split APKs are what you attach to a release: a universal APK carries arm64, +armeabi and x86_64 native code, and virtually every real phone is +`arm64-v8a`. Attach the universal one too, as the no-guesswork fallback. + +Verify what you built: + +```sh +# Confirm it is NOT the debug key. Should print your CN, not "Android Debug". +keytool -printcert -jarfile build/app/outputs/flutter-apk/app-arm64-v8a-release.apk +``` + +## Publish + +Attach the APKs to the GitHub Release. In the release notes, tell people what +Android will show them: + +> Android will warn that this APK is from an unknown developer, because it is +> signed with our own key rather than distributed through the Play Store. You +> will need to allow installation from your browser or file manager. The +> SHA-256 fingerprint of the signing key is `` — it will +> not change between releases. + +Publishing the fingerprint is what makes self-signing trustworthy: it lets +anyone confirm a later APK came from the same key. + +## Why not the debug key + +Flutter's template signs release builds with the debug keystore. That build +runs, which is why it is a usable fallback, but it must never be published: + +- The debug keystore is generated per machine, so builds from two machines are + mutually un-upgradable. +- It expires after 365 days. +- Android refuses to install an update signed with a different key than the + installed version. + +An APK published with a debug key strands every person who installs it. + +## Why no Play Store + +Play requires a Google Play Developer account ($25), and since August 2023 new +personal developer accounts must complete a 14-day closed test with 12 testers +before production access. For a tool aimed at people transferring files across +an air gap, a directly downloadable APK is the better distribution channel +anyway. F-Droid is a reasonable future option — it needs a reproducible build +and a metadata submission, and it is not a blocker for launch. diff --git a/flutter/android/app/build.gradle.kts b/flutter/android/app/build.gradle.kts index 28d6f97..00cdaa9 100644 --- a/flutter/android/app/build.gradle.kts +++ b/flutter/android/app/build.gradle.kts @@ -1,9 +1,22 @@ +import java.util.Properties + plugins { id("com.android.application") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } +// Release signing is opt-in: `android/key.properties` is gitignored and absent +// on a fresh clone and in CI. When it is missing the release build falls back +// to the debug keystore, so `flutter build apk --release` still works for +// anyone building for themselves. Only the published APK needs the real key — +// see docs/RELEASING-ANDROID.md. +val keystoreProperties = Properties().apply { + val f = rootProject.file("key.properties") + if (f.exists()) f.inputStream().use { load(it) } +} +val hasReleaseKeystore = keystoreProperties.getProperty("storeFile") != null + android { namespace = "com.goranninkovic.porterrx" compileSdk = flutter.compileSdkVersion @@ -15,7 +28,6 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.goranninkovic.porterrx" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. @@ -25,11 +37,28 @@ android { versionName = flutter.versionName } + signingConfigs { + if (hasReleaseKeystore) { + create("release") { + storeFile = rootProject.file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // The debug fallback is deliberate, but it is NOT publishable: the + // debug keystore is generated per machine and expires after a year, + // and Android refuses an update signed with a different key. A + // published APK signed this way could never be upgraded. + signingConfig = if (hasReleaseKeystore) { + signingConfigs.getByName("release") + } else { + signingConfigs.getByName("debug") + } } } } diff --git a/mise.toml b/mise.toml index aeae109..5ff7c6a 100644 --- a/mise.toml +++ b/mise.toml @@ -20,6 +20,19 @@ ARCHS=arm64 \ sh macos/strip_x86_64.sh """ +[tasks.flutter-apk] +dir = "flutter" +# Universal APK — every ABI in one file. Bigger, but the safe download when +# you don't know the target device. Falls back to the debug keystore unless +# android/key.properties exists; see docs/RELEASING-ANDROID.md. +run = "flutter build apk --release" + +[tasks.flutter-apk-split] +dir = "flutter" +# One APK per ABI, roughly a third the size each. These are what to attach to +# a GitHub Release; virtually every real phone wants arm64-v8a. +run = "flutter build apk --release --split-per-abi" + [tasks.flutter-test] dir = "flutter" run = "flutter analyze && flutter test"