Skip to content

Commit d79c553

Browse files
authored
Release v1.1.5: per-ABI APK splits (arm64-v8a, x86_64) (#382)
* Release v1.1.5: per-ABI APK splits (arm64-v8a, x86_64) for F-Droid * Align docs/CI with per-ABI APK splits; harden reproducibility download guard
1 parent fa75e4f commit d79c553

15 files changed

Lines changed: 127 additions & 49 deletions

File tree

.github/workflows/release.yml

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,20 @@ jobs:
7575
--secret id=key_pass,env=KEY_PASSWORD \
7676
--output type=local,dest=docker-out \
7777
-f Dockerfile.reproducible .
78-
test -f docker-out/app-release.apk
79-
mv docker-out/app-release.apk "keep-android-${REF_NAME}.apk"
78+
# ABI splits produce one APK per architecture (app-<abi>-release.apk).
79+
# Rename each to a release asset: keep-android-<tag>-<abi>.apk
80+
shopt -s nullglob
81+
count=0
82+
for apk in docker-out/app-*-release.apk; do
83+
abi="$(basename "$apk" | sed -E 's/^app-(.*)-release\.apk$/\1/')"
84+
mv "$apk" "keep-android-${REF_NAME}-${abi}.apk"
85+
count=$((count + 1))
86+
done
87+
if [ "$count" -lt 2 ]; then
88+
echo "error: expected at least 2 per-ABI APKs, got $count" >&2
89+
exit 1
90+
fi
91+
ls -la "keep-android-${REF_NAME}"-*.apk
8092
8193
- name: Setup Android SDK
8294
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
@@ -94,25 +106,34 @@ jobs:
94106
EXPECTED_CERT_SHA256: 3eb0e6f6f4e0962ebd717f84eea47428b73087b686c956633d6a9fcc4c854f50
95107
run: |
96108
APKSIGNER="$ANDROID_HOME/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner"
97-
CERTS="$("$APKSIGNER" verify --print-certs "keep-android-${REF_NAME}.apk")"
98-
echo "$CERTS"
99-
ACTUAL="$(printf '%s\n' "$CERTS" \
100-
| sed -nE 's/.*certificate SHA-256 digest:[[:space:]]*([0-9a-fA-F]+).*/\1/p' \
101-
| head -1 | tr 'A-F' 'a-f')"
102-
if [ -z "$ACTUAL" ]; then
103-
echo "::error::could not parse signing certificate SHA-256 digest" >&2
104-
exit 1
105-
fi
106-
if [ "$ACTUAL" != "$EXPECTED_CERT_SHA256" ]; then
107-
echo "::error::signing cert SHA-256 $ACTUAL does not match expected release key $EXPECTED_CERT_SHA256" >&2
109+
shopt -s nullglob
110+
checked=0
111+
for apk in "keep-android-${REF_NAME}"-*.apk; do
112+
CERTS="$("$APKSIGNER" verify --print-certs "$apk")"
113+
echo "$apk:"; echo "$CERTS"
114+
ACTUAL="$(printf '%s\n' "$CERTS" \
115+
| sed -nE 's/.*certificate SHA-256 digest:[[:space:]]*([0-9a-fA-F]+).*/\1/p' \
116+
| head -1 | tr 'A-F' 'a-f')"
117+
if [ -z "$ACTUAL" ]; then
118+
echo "::error::$apk: could not parse signing certificate SHA-256 digest" >&2
119+
exit 1
120+
fi
121+
if [ "$ACTUAL" != "$EXPECTED_CERT_SHA256" ]; then
122+
echo "::error::$apk signing cert SHA-256 $ACTUAL does not match expected release key $EXPECTED_CERT_SHA256" >&2
123+
exit 1
124+
fi
125+
checked=$((checked + 1))
126+
done
127+
if [ "$checked" -lt 2 ]; then
128+
echo "::error::expected to verify at least 2 per-ABI APKs, verified $checked" >&2
108129
exit 1
109130
fi
110-
echo "Signing certificate matches the expected release key."
131+
echo "All $checked per-ABI APK signing certificates match the expected release key."
111132
112133
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
113134
with:
114135
name: apk
115-
path: keep-android-${{ github.ref_name }}.apk
136+
path: keep-android-${{ github.ref_name }}-*.apk
116137

117138
release:
118139
needs: [build]

.github/workflows/reproducibility.yml

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ jobs:
8787
--build-arg KEEP_SHA="$(tr -d '[:space:]' < keep.version)" \
8888
--output type=local,dest=out \
8989
-f Dockerfile.reproducible .
90-
test -f out/app-release.apk
90+
# ABI splits produce one APK per architecture.
91+
ls out/app-*-release.apk
92+
test "$(ls out/app-*-release.apk | wc -l)" -ge 2
9193
9294
- name: Setup Android SDK
9395
if: github.event_name != 'pull_request'
@@ -117,14 +119,22 @@ jobs:
117119
REPOSITORY: ${{ github.repository }}
118120
run: |
119121
echo "Verifying reproduction of $VERSION"
120-
URL="https://github.com/${REPOSITORY}/releases/download/${VERSION}/keep-android-${VERSION}.apk"
121-
# On a tag push this runs concurrently with release.yml, so the asset
122-
# may not be published yet. Wait for it (up to ~20 min).
123-
for i in $(seq 1 40); do
124-
if curl -fsSL -o published.apk "$URL"; then break; fi
125-
echo "Published APK not available yet (attempt $i/40); waiting 30s..."
126-
sleep 30
122+
# One published APK per architecture; compare each to its container build.
123+
for built in out/app-*-release.apk; do
124+
abi="$(basename "$built" | sed -E 's/^app-(.*)-release\.apk$/\1/')"
125+
URL="https://github.com/${REPOSITORY}/releases/download/${VERSION}/keep-android-${VERSION}-${abi}.apk"
126+
# On a tag push this runs concurrently with release.yml, so the asset
127+
# may not be published yet. Wait for it (up to ~20 min).
128+
ok=""
129+
for i in $(seq 1 40); do
130+
if curl -fsSL -o "published-${abi}.apk" "$URL"; then ok=1; break; fi
131+
echo "Published $abi APK not available yet (attempt $i/40); waiting 30s..."
132+
sleep 30
133+
done
134+
if [ -z "$ok" ] || ! test -s "published-${abi}.apk"; then
135+
echo "::error::published $abi APK never became available at $URL" >&2
136+
exit 1
137+
fi
138+
apksigcopier compare "published-${abi}.apk" "$built"
139+
echo "Container build reproduces the published $VERSION $abi APK (payload identical)."
127140
done
128-
test -s published.apk
129-
apksigcopier compare published.apk out/app-release.apk
130-
echo "Container build reproduces the published $VERSION APK (payload identical)."

Dockerfile.reproducible

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
# --output type=local,dest=out \
1414
# -f Dockerfile.reproducible .
1515
#
16-
# Output: ./out/app-release.apk (unsigned; sign with your own key or compare
17-
# against the published APK with diffoscopethe payload is reproducible,
18-
# only the signing block necessarily differs).
16+
# Output: ./out/app-<abi>-release.apk, one per ABI (arm64-v8a, x86_64). Sign
17+
# with your own key or compare against the published APK with diffoscope; the
18+
# payload is reproducible, only the signing block necessarily differs.
1919

2020
# Pinned base: Debian 13 (trixie), slim variant. This matches F-Droid's
2121
# buildserver OS so the published APK reproduces what F-Droid builds from the
@@ -293,16 +293,20 @@ RUN --mount=type=secret,id=keystore,uid=1000 \
293293
export KEYSTORE_FILE KEYSTORE_PASSWORD KEY_ALIAS KEY_PASSWORD; \
294294
./gradlew :app:verifyNoProprietaryDeps assembleRelease --no-daemon; \
295295
mkdir -p /out; \
296-
cp app/build/outputs/apk/release/app-release.apk /out/app-release.apk; \
297-
echo "SHA256 of signed APK (depends on signing key; differs per build when"; \
298-
echo "using the throwaway keystore, since the RSA key is regenerated):"; \
299-
sha256sum /out/app-release.apk; \
300-
echo ""; \
296+
# ABI splits (isUniversalApk=false) produce one APK per architecture. Copy
297+
# every per-ABI release APK; the export stage and release.yml publish them all.
298+
found=""; \
299+
for apk in app/build/outputs/apk/release/*-release.apk; do \
300+
[ -f "$apk" ] || continue; \
301+
cp "$apk" /out/; found="yes"; \
302+
echo "SHA256 $(sha256sum "$apk")"; \
303+
done; \
304+
[ -n "$found" ] || { echo "no release APKs produced" >&2; exit 1; }; \
301305
echo "For reproducibility verification, compare APKs with diffoscope"; \
302306
echo "(see REPRODUCIBLE_BUILDS.md § 5.2). A matching signed-APK sha256 is"; \
303307
echo "only expected when the same KEYSTORE_FILE/PASSWORD/ALIAS is reused."
304308

305-
# Export stage: extract just the APK using BuildKit's local output.
309+
# Export stage: extract the per-ABI APKs using BuildKit's local output.
306310
# docker build --output type=local,dest=out -f Dockerfile.reproducible .
307311
FROM scratch AS export
308-
COPY --from=builder /out/app-release.apk /app-release.apk
312+
COPY --from=builder /out/ /

REPRODUCIBLE_BUILDS.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export SOURCE_DATE_EPOCH="$(./scripts/derive-sde.sh)"
142142
# container paths:
143143
# * Host build: if no `KEYSTORE_FILE` is exported and no `storeFile` is
144144
# resolved by `app/build.gradle.kts`, Gradle falls back to the debug
145-
# signing key and still writes `app-release.apk`.
145+
# signing key and still writes the per-ABI `app-<abi>-release.apk` files.
146146
# * Container build (Section 4): `Dockerfile.reproducible` intentionally
147147
# generates a throwaway keystore per build when `KEYSTORE_FILE` is unset,
148148
# so the APK is always release-signed. This enforces a deterministic
@@ -153,7 +153,8 @@ export SOURCE_DATE_EPOCH="$(./scripts/derive-sde.sh)"
153153
./gradlew assembleRelease --no-daemon
154154
```
155155

156-
Output: `app/build/outputs/apk/release/app-release.apk`.
156+
Output: per-ABI APKs in `app/build/outputs/apk/release/`, namely
157+
`app-arm64-v8a-release.apk` and `app-x86_64-release.apk`.
157158

158159
If you want to reproduce the exact byte layout of the published APK, you must
159160
sign with the same release key; otherwise the APK payload is identical but the
@@ -187,7 +188,7 @@ DOCKER_BUILDKIT=1 docker build \
187188
-f Dockerfile.reproducible \
188189
.
189190

190-
ls out/ # app-release.apk
191+
ls out/ # app-arm64-v8a-release.apk app-x86_64-release.apk
191192
```
192193

193194
The container:
@@ -198,7 +199,7 @@ The container:
198199
3. Copies the keep-android sources in and clones `keep` at the pinned SHA.
199200
4. Runs `build-rust.sh` and `./gradlew assembleRelease` with
200201
`SOURCE_DATE_EPOCH` set.
201-
5. Exports the signed release APK to `./out/` (signed with a per-build
202+
5. Exports the signed per-ABI release APKs to `./out/` (signed with a per-build
202203
throwaway keystore unless a keystore is supplied; see § 4.1).
203204

204205
### 4.1 Optional build-args
@@ -257,7 +258,7 @@ sha256sum -c SHA256SUMS
257258
sudo apt-get install -y diffoscope
258259
diffoscope --html diffoscope.html --text diffoscope.txt \
259260
keep-android-v0.5.2.apk \
260-
app/build/outputs/apk/release/app-release.apk
261+
app/build/outputs/apk/release/app-arm64-v8a-release.apk
261262
```
262263

263264
A reproducible build produces a `diffoscope` report in which:
@@ -288,18 +289,19 @@ is removed explicitly so the sibling cargo target dir is purged even when
288289
rm -rf app/src/main/jniLibs app/src/main/kotlin/io/privkey/keep/uniffi "$KEEP_REPO/keep-mobile/target"
289290
./build-rust.sh
290291
./gradlew assembleRelease --no-daemon
291-
cp app/build/outputs/apk/release/app-release.apk /tmp/build1.apk
292+
cp app/build/outputs/apk/release/app-arm64-v8a-release.apk /tmp/build1.apk
292293

293294
./gradlew clean --no-daemon
294295
rm -rf app/src/main/jniLibs app/src/main/kotlin/io/privkey/keep/uniffi "$KEEP_REPO/keep-mobile/target"
295296
./build-rust.sh
296297
./gradlew assembleRelease --no-daemon
297-
cp app/build/outputs/apk/release/app-release.apk /tmp/build2.apk
298+
cp app/build/outputs/apk/release/app-arm64-v8a-release.apk /tmp/build2.apk
298299

299300
sha256sum /tmp/build1.apk /tmp/build2.apk
300301
```
301302

302-
The two SHA-256 hashes MUST be identical.
303+
The two SHA-256 hashes MUST be identical. Repeat the comparison for
304+
`app-x86_64-release.apk`; each per-ABI APK must reproduce independently.
303305

304306
## 6. Troubleshooting
305307

app/build.gradle.kts

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,16 @@ android {
1313
applicationId = "io.privkey.keep"
1414
minSdk = 33
1515
targetSdk = 36
16-
versionCode = 22
17-
versionName = "1.1.4"
16+
versionCode = 23
17+
versionName = "1.1.5"
1818

1919
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
2020
vectorDrawables {
2121
useSupportLibrary = true
2222
}
23-
ndk {
24-
abiFilters += listOf("arm64-v8a", "x86_64")
25-
}
23+
// ABI selection is handled by the `splits { abi }` block below (which is
24+
// incompatible with ndk.abiFilters). splits.abi.include restricts the
25+
// packaged architectures to arm64-v8a and x86_64.
2626
}
2727

2828
// Reproducible builds: strip non-reproducible Play dependency metadata blob
@@ -88,12 +88,43 @@ android {
8888
}
8989
}
9090

91+
// Per-ABI APK splits for F-Droid: ship one APK per architecture instead of a
92+
// single universal APK. Each split gets a distinct versionCode assigned in
93+
// the androidComponents block below.
94+
splits {
95+
abi {
96+
isEnable = true
97+
reset()
98+
include("arm64-v8a", "x86_64")
99+
isUniversalApk = false
100+
}
101+
}
102+
91103
testOptions {
92104
unitTests.isReturnDefaultValues = true
93105
}
94106

95107
}
96108

109+
// Assign each per-ABI release split its own versionCode: 10 * base + abiCode.
110+
// ABI codes follow F-Droid's required ordering (armeabi-v7a < arm64-v8a < x86 <
111+
// x86_64), yielding 232 (arm64-v8a) and 234 (x86_64). The F-Droid recipe must
112+
// declare matching per-ABI versionCodes for these splits.
113+
val abiVersionCodes = mapOf("arm64-v8a" to 2, "x86_64" to 4)
114+
androidComponents {
115+
onVariants(selector().withBuildType("release")) { variant ->
116+
variant.outputs.forEach { output ->
117+
val abi = output.filters
118+
.firstOrNull { it.filterType == com.android.build.api.variant.FilterConfiguration.FilterType.ABI }
119+
?.identifier
120+
val abiCode = abiVersionCodes[abi]
121+
if (abiCode != null) {
122+
output.versionCode.set(10 * (android.defaultConfig.versionCode ?: 0) + abiCode)
123+
}
124+
}
125+
}
126+
}
127+
97128
kotlin {
98129
compilerOptions {
99130
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Aufteilung in architekturspezifische APKs (arm64-v8a, x86_64) für kleinere Downloads. Keine funktionalen Änderungen gegenüber 1.1.4.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Aufteilung in architekturspezifische APKs (arm64-v8a, x86_64) für kleinere Downloads. Keine funktionalen Änderungen gegenüber 1.1.4.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Split into per-architecture APKs (arm64-v8a, x86_64) for smaller downloads. No functional changes since 1.1.4.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Split into per-architecture APKs (arm64-v8a, x86_64) for smaller downloads. No functional changes since 1.1.4.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Divididos en APKs por arquitectura (arm64-v8a, x86_64) para descargas más pequeñas. Sin cambios funcionales desde la 1.1.4.

0 commit comments

Comments
 (0)