Adding support for Obtainium with fixed Signing Keys and apk Release publishing
This commit is contained in:
@@ -39,7 +39,7 @@ jobs:
|
|||||||
- name: Install the download tools
|
- name: Install the download tools
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y --no-install-recommends ca-certificates curl xz-utils
|
apt-get install -y --no-install-recommends ca-certificates curl xz-utils jq
|
||||||
|
|
||||||
- name: Set up Java
|
- name: Set up Java
|
||||||
uses: https://github.com/actions/setup-java@v4
|
uses: https://github.com/actions/setup-java@v4
|
||||||
@@ -74,21 +74,110 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: flutter test
|
run: flutter test
|
||||||
|
|
||||||
|
# Android accepts an update only when it is signed with the same key as
|
||||||
|
# the installed app, so every release has to carry one key that never
|
||||||
|
# changes. The debug key cannot be used: the runner generates a fresh one
|
||||||
|
# on every run, which would make each apk a different app to android and
|
||||||
|
# break every update through Obtainium.
|
||||||
|
#
|
||||||
|
# `android/app/build.gradle.kts` picks the key up from
|
||||||
|
# `android/key.properties`, which is written here from the repository
|
||||||
|
# secrets (Settings -> Actions -> Secrets). See CLAUDE.md for the
|
||||||
|
# `keytool` command that creates the keystore.
|
||||||
|
- name: Write the release signing key
|
||||||
|
env:
|
||||||
|
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Failing here is on purpose: a silently debug signed apk would be
|
||||||
|
# installable once and never updatable afterwards.
|
||||||
|
if [ -z "${KEYSTORE_BASE64:-}" ]; then
|
||||||
|
echo "::error::The secret ANDROID_KEYSTORE_BASE64 is empty. Add the keystore secrets (see CLAUDE.md) before publishing a release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$KEYSTORE_BASE64" | base64 -d > android/app/release-keystore.jks
|
||||||
|
|
||||||
|
# storeFile is relative to `android/app`. The file is gitignored.
|
||||||
|
cat > android/key.properties <<PROPERTIES
|
||||||
|
storeFile=release-keystore.jks
|
||||||
|
storePassword=$KEYSTORE_PASSWORD
|
||||||
|
keyAlias=$KEY_ALIAS
|
||||||
|
keyPassword=$KEY_PASSWORD
|
||||||
|
PROPERTIES
|
||||||
|
|
||||||
# One apk for all abis. `--split-per-abi` would produce three smaller
|
# One apk for all abis. `--split-per-abi` would produce three smaller
|
||||||
# ones instead, at the cost of having to pick the right one manually.
|
# ones instead, at the cost of having to pick the right one manually.
|
||||||
#
|
#
|
||||||
# The release build is signed with a debug key, because
|
# The version in the pubspec (`1.0.0+1`) never moves, so it is replaced
|
||||||
# `android/app/build.gradle.kts` has no release signing config yet. To
|
# here: the version name becomes the tag of the release and the version
|
||||||
# ship the apk, add a keystore as a secret, write it into
|
# code the run number, which only ever grows. Android refuses to install
|
||||||
# `android/key.properties` here and reference it from the gradle file.
|
# an apk whose version code is lower than the installed one, so the code
|
||||||
|
# must never go backwards.
|
||||||
- name: Build the apk
|
- name: Build the apk
|
||||||
run: flutter build apk --release
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
version="${{ github.ref_name }}"
|
||||||
|
version="${version#v}"
|
||||||
|
|
||||||
|
# `--build-name` only accepts a dotted number, so an unusual tag
|
||||||
|
# ("nightly") keeps the version of the pubspec instead of failing
|
||||||
|
# the build.
|
||||||
|
case "$version" in
|
||||||
|
*[!0-9.]* | '' ) buildName='' ;;
|
||||||
|
* ) buildName="$version" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ -n "$buildName" ]; then
|
||||||
|
flutter build apk --release \
|
||||||
|
--build-name "$buildName" \
|
||||||
|
--build-number "${{ github.run_number }}"
|
||||||
|
else
|
||||||
|
echo "::warning::'${{ github.ref_name }}' is not a dotted version, keeping the version of the pubspec."
|
||||||
|
flutter build apk --release --build-number "${{ github.run_number }}"
|
||||||
|
fi
|
||||||
|
|
||||||
# Gradle always writes the same file name, so the release the apk belongs
|
# Gradle always writes the same file name, so the release the apk belongs
|
||||||
# to would not be visible after downloading it.
|
# to would not be visible after downloading it.
|
||||||
- name: Name the apk after the release
|
- name: Name the apk after the release
|
||||||
run: mv build/app/outputs/flutter-apk/app-release.apk ac_web-${{ github.ref_name }}.apk
|
run: mv build/app/outputs/flutter-apk/app-release.apk ac_web-${{ github.ref_name }}.apk
|
||||||
|
|
||||||
|
# Obtainium reads `<host>/api/v1/repos/<owner>/<repo>/releases` and
|
||||||
|
# downloads the *assets* of a release, so the apk has to hang off the
|
||||||
|
# release itself. A workflow artifact is invisible to it: it lives under
|
||||||
|
# the run, is zipped and needs a login.
|
||||||
|
- name: Attach the apk to the release
|
||||||
|
env:
|
||||||
|
# The token gitea creates for the run. If the instance hands out a
|
||||||
|
# read only token, put a personal access token with repository write
|
||||||
|
# access in a secret and use that instead.
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||||
|
asset="ac_web-${{ github.ref_name }}.apk"
|
||||||
|
|
||||||
|
# The release event carries the id; the lookup by tag is only there
|
||||||
|
# for a manual re-run of the job.
|
||||||
|
releaseId="${{ github.event.release.id }}"
|
||||||
|
if [ -z "$releaseId" ] || [ "$releaseId" = "null" ]; then
|
||||||
|
releaseId=$(curl -fsSL -H "Authorization: token $TOKEN" \
|
||||||
|
"$api/releases/tags/${{ github.ref_name }}" | jq -r '.id')
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsSL -X POST \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$asset" \
|
||||||
|
"$api/releases/$releaseId/assets?name=$asset" > /dev/null
|
||||||
|
|
||||||
|
echo "Attached $asset to release $releaseId."
|
||||||
|
|
||||||
# @v3 on purpose: `upload-artifact@v4` bundles `@actions/artifact` v2,
|
# @v3 on purpose: `upload-artifact@v4` bundles `@actions/artifact` v2,
|
||||||
# which refuses to run as soon as `GITHUB_SERVER_URL` is not github.com
|
# which refuses to run as soon as `GITHUB_SERVER_URL` is not github.com
|
||||||
# ("GHESNotSupportedError") -- and on this instance it is the gitea host.
|
# ("GHESNotSupportedError") -- and on this instance it is the gitea host.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
clang cmake ninja-build pkg-config \
|
clang cmake ninja-build pkg-config \
|
||||||
libgtk-3-dev liblzma-dev libstdc++-12-dev \
|
libgtk-3-dev liblzma-dev libstdc++-12-dev \
|
||||||
curl unzip xz-utils
|
curl unzip xz-utils jq
|
||||||
|
|
||||||
# `subosito/flutter-action` cannot be used: it caches the sdk through
|
# `subosito/flutter-action` cannot be used: it caches the sdk through
|
||||||
# `actions/cache@v5`, and the act-runner of this gitea instance only
|
# `actions/cache@v5`, and the act-runner of this gitea instance only
|
||||||
@@ -72,6 +72,36 @@ jobs:
|
|||||||
- name: Pack the bundle
|
- name: Pack the bundle
|
||||||
run: tar -czf ac_web-linux-x64-${{ github.ref_name }}.tar.gz -C build/linux/x64/release bundle
|
run: tar -czf ac_web-linux-x64-${{ github.ref_name }}.tar.gz -C build/linux/x64/release bundle
|
||||||
|
|
||||||
|
# The tarball hangs off the release itself, next to the apk of the
|
||||||
|
# android job, so it can be downloaded without a login and stays after
|
||||||
|
# the artifacts of the run have been pruned.
|
||||||
|
- name: Attach the bundle to the release
|
||||||
|
env:
|
||||||
|
# The token gitea creates for the run. If the instance hands out a
|
||||||
|
# read only token, put a personal access token with repository write
|
||||||
|
# access in a secret and use that instead.
|
||||||
|
TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||||
|
asset="ac_web-linux-x64-${{ github.ref_name }}.tar.gz"
|
||||||
|
|
||||||
|
# The release event carries the id; the lookup by tag is only there
|
||||||
|
# for a manual re-run of the job.
|
||||||
|
releaseId="${{ github.event.release.id }}"
|
||||||
|
if [ -z "$releaseId" ] || [ "$releaseId" = "null" ]; then
|
||||||
|
releaseId=$(curl -fsSL -H "Authorization: token $TOKEN" \
|
||||||
|
"$api/releases/tags/${{ github.ref_name }}" | jq -r '.id')
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsSL -X POST \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-F "attachment=@$asset" \
|
||||||
|
"$api/releases/$releaseId/assets?name=$asset" > /dev/null
|
||||||
|
|
||||||
|
echo "Attached $asset to release $releaseId."
|
||||||
|
|
||||||
# @v3 on purpose: `upload-artifact@v4` bundles `@actions/artifact` v2,
|
# @v3 on purpose: `upload-artifact@v4` bundles `@actions/artifact` v2,
|
||||||
# which refuses to run as soon as `GITHUB_SERVER_URL` is not github.com
|
# which refuses to run as soon as `GITHUB_SERVER_URL` is not github.com
|
||||||
# ("GHESNotSupportedError") -- and on this instance it is the gitea host.
|
# ("GHESNotSupportedError") -- and on this instance it is the gitea host.
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ Widget tests run against a mocked backend: build a `MockClient` that answers by
|
|||||||
|
|
||||||
| Workflow | Builds | Result |
|
| Workflow | Builds | Result |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `BuildLinux.yml` | `flutter build linux --release` | `ac_web-linux-x64-<tag>.tar.gz` (the whole bundle directory) as an artifact |
|
| `BuildLinux.yml` | `flutter build linux --release` | `ac_web-linux-x64-<tag>.tar.gz` (the whole bundle directory), attached to the release and uploaded as an artifact |
|
||||||
| `BuildAndroid.yml` | `flutter build apk --release` | `ac_web-<tag>.apk` as an artifact |
|
| `BuildAndroid.yml` | `flutter build apk --release` | `ac_web-<tag>.apk`, attached to the release and uploaded as an artifact |
|
||||||
| `PublishDocker.yml` | `ac_web/Dockerfile` | image pushed to the Gitea registry as `<tag>`, `latest` and the short sha |
|
| `PublishDocker.yml` | `ac_web/Dockerfile` | image pushed to the Gitea registry as `<tag>`, `latest` and the short sha |
|
||||||
|
|
||||||
Notes that are easy to get wrong:
|
Notes that are easy to get wrong:
|
||||||
@@ -50,7 +50,17 @@ Notes that are easy to get wrong:
|
|||||||
- **`actions/upload-artifact` must stay at `@v3`.** `@v4` bundles `@actions/artifact` v2, which aborts with `GHESNotSupportedError` whenever `GITHUB_SERVER_URL` is not `github.com` -- on this instance it is the gitea host, so the check fires before any request and cannot be worked around on the server side. `@v3` uses the older artifact api gitea implements.
|
- **`actions/upload-artifact` must stay at `@v3`.** `@v4` bundles `@actions/artifact` v2, which aborts with `GHESNotSupportedError` whenever `GITHUB_SERVER_URL` is not `github.com` -- on this instance it is the gitea host, so the check fires before any request and cannot be worked around on the server side. `@v3` uses the older artifact api gitea implements.
|
||||||
- The workflows are read from the tag the release points at, so they have to be committed **before** the release is created.
|
- The workflows are read from the tag the release points at, so they have to be committed **before** the release is created.
|
||||||
- Artifacts and the built files carry the tag name (`github.ref_name`); the docker image is pushed as the tag, as `latest` and as the short sha.
|
- Artifacts and the built files carry the tag name (`github.ref_name`); the docker image is pushed as the tag, as `latest` and as the short sha.
|
||||||
- The apk is signed with a debug key, because `android/app/build.gradle.kts` still has no release signing config.
|
- **The built files are attached to the release, not only uploaded as artifacts.** Artifacts live under the workflow run, are zipped and need a login; a release asset is a plain download on the release page. The upload is a `curl` against `POST <server>/api/v1/repos/<owner>/<repo>/releases/<id>/assets?name=<file>` with `${{ github.token }}`, and the release id comes from the event (falling back to a lookup by tag for a manual re-run). Obtainium needs the apk exactly there, see below.
|
||||||
|
- **The apk must keep one signing key forever.** Android identifies an app by its application id *and* its certificate, so an update signed with a different key is rejected (`INSTALL_FAILED_UPDATE_INCOMPATIBLE`) and the app has to be uninstalled first. The debug key is regenerated on every runner, so it can never be used for a published apk. `android/app/build.gradle.kts` reads the key from `android/key.properties` (gitignored) and falls back to the debug key when that file is missing, so `flutter run --release` still works locally. The workflow writes the file from four secrets and fails loudly when the first one is empty: `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Create the keystore once and keep a backup — losing it means every installed app has to be uninstalled before it can be updated again:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
keytool -genkeypair -v -keystore ac_web-release.p12 -storetype PKCS12 \
|
||||||
|
-keyalg RSA -keysize 2048 -validity 10000 -alias ac-web
|
||||||
|
base64 -w0 ac_web-release.p12 # the value of ANDROID_KEYSTORE_BASE64
|
||||||
|
```
|
||||||
|
|
||||||
|
- **The apk version comes from the release, not from the pubspec.** `version: 1.0.0+1` never moves, so the workflow passes `--build-name <tag without a leading v>` and `--build-number ${{ github.run_number }}`; the run number only grows, and android refuses an apk whose version code is lower than the installed one. A tag that is not a dotted number keeps the pubspec version and only warns.
|
||||||
|
- **Obtainium** tracks the app through the Gitea api: add the repository url (`https://<host>/ArtificialCompany/AcWeb`) and pick `Codeberg (Forgejo)` under "Override Source" — self hosted instances are not detected automatically, but the source builds its api calls from the host in the url. It reads `/api/v1/repos/.../releases` and downloads the release assets, so the repository has to be readable without a login from the phone.
|
||||||
- The docker image is self contained: it compiles the web bundle itself, so `docker build -f ac_web/Dockerfile ac_web` gives the same result locally. It serves the static bundle with nginx (`ac_web/nginx.conf`), which falls back to `index.html` so deep links survive a reload. The backend address is compiled into the bundle, so the host in `main.dart` must be reachable from the browser of every visitor, not only from the container.
|
- The docker image is self contained: it compiles the web bundle itself, so `docker build -f ac_web/Dockerfile ac_web` gives the same result locally. It serves the static bundle with nginx (`ac_web/nginx.conf`), which falls back to `index.html` so deep links survive a reload. The backend address is compiled into the bundle, so the host in `main.dart` must be reachable from the browser of every visitor, not only from the container.
|
||||||
|
|
||||||
## Backend connection
|
## Backend connection
|
||||||
|
|||||||
@@ -1,9 +1,31 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Android identifies an app by its application id *and* the certificate it was
|
||||||
|
// signed with, so an update is only accepted when it carries the same
|
||||||
|
// signature as the installed app. Obtainium therefore needs every release to
|
||||||
|
// be signed with one key that never changes - the debug key does not work, it
|
||||||
|
// is generated per machine and a fresh one appears on every ci run.
|
||||||
|
//
|
||||||
|
// The key itself is described by `android/key.properties` (storeFile,
|
||||||
|
// storePassword, keyAlias, keyPassword). That file and the keystore are
|
||||||
|
// gitignored and never committed: a developer creates them once locally, the
|
||||||
|
// workflow writes them from the repository secrets. Without the file the
|
||||||
|
// release build falls back to the debug key so that `flutter run --release`
|
||||||
|
// keeps working on a machine that has no keystore.
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val hasReleaseKeystore = keystorePropertiesFile.exists()
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
|
||||||
|
if (hasReleaseKeystore) {
|
||||||
|
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.fscheffers.ac_web"
|
namespace = "com.fscheffers.ac_web"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -15,21 +37,37 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.fscheffers.ac_web"
|
applicationId = "com.fscheffers.ac_web"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
// Both come from the pubspec (`version: <name>+<code>`); the workflow
|
||||||
|
// overrides them with the release tag and the run number.
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (hasReleaseKeystore) {
|
||||||
|
create("release") {
|
||||||
|
// A relative storeFile is resolved against `android/app`, an
|
||||||
|
// absolute one is taken as it is.
|
||||||
|
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = if (hasReleaseKeystore) {
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
signingConfigs.getByName("release")
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user