fix(android): enforce consistent APK signing

This commit is contained in:
tianma-if committed 2026-07-31 11:41:32 +08:00
1 parent 01be5293e2
commit 16daa4155f
8 files changed
+250 -46

No files matched your search

+36 -11
View File
@@ -17,7 +17,9 @@ on:
- "packages/client/**"
- "packages/shared/**"
- "bun.lock"
- ".github/workflows/mobile-build.yml"
- "scripts/build-android-local.sh"
- "scripts/verify-android-apk-signature.mjs"
permissions:
contents: write
@@ -117,6 +119,21 @@ jobs:
exit 1
fi
- name: Verify existing APK uses the pinned signer
if: steps.plan.outputs.asset_ready == 'true'
env:
CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p "$RUNNER_TEMP/native-assets"
gh release download "$CURRENT_TAG" \
--repo "$GITHUB_REPOSITORY" \
--pattern 'edgeever-android-v*-arm64-v8a.apk' \
--dir "$RUNNER_TEMP/native-assets"
apk_path="$(find "$RUNNER_TEMP/native-assets" -type f -name 'edgeever-android-v*-arm64-v8a.apk' -print -quit)"
test -n "$apk_path"
node scripts/verify-android-apk-signature.mjs "$apk_path"
reuse-release-apk:
needs: release-plan
if: >-
@@ -130,6 +147,9 @@ jobs:
name: Reuse Android release APK
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Copy latest compatible APK without renaming
env:
CURRENT_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
@@ -142,6 +162,8 @@ jobs:
--pattern 'edgeever-android-v*-arm64-v8a.apk' \
--dir "$RUNNER_TEMP/native-assets"
test "$(find "$RUNNER_TEMP/native-assets" -type f -name 'edgeever-android-v*-arm64-v8a.apk' | wc -l | tr -d ' ')" = "1"
apk_path="$(find "$RUNNER_TEMP/native-assets" -type f -name 'edgeever-android-v*-arm64-v8a.apk' -print -quit)"
node scripts/verify-android-apk-signature.mjs "$apk_path"
gh release upload "$CURRENT_TAG" "$RUNNER_TEMP/native-assets/"* \
--repo "$GITHUB_REPOSITORY" \
--clobber
@@ -190,12 +212,7 @@ jobs:
- name: Reset Metro cache for this checkout
run: bun --cwd apps/mobile expo export --platform android --clear --max-workers 1 --output-dir "$RUNNER_TEMP/edgeever-metro-warmup"
- name: Build fast test APK
if: github.event_name == 'push'
run: bun run build:android:fast
- name: Restore Android upload keystore
if: github.event_name == 'workflow_dispatch'
- name: Restore Android signing keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
@@ -203,6 +220,16 @@ jobs:
printf '%s' "$ANDROID_KEYSTORE_BASE64" | openssl base64 -d -A > "$RUNNER_TEMP/edgeever-upload.p12"
chmod 600 "$RUNNER_TEMP/edgeever-upload.p12"
- name: Build fast main-branch APK
if: github.event_name == 'push'
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEYSTORE_FILE: ${{ runner.temp }}/edgeever-upload.p12
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
EDGE_EVER_ANDROID_APK_ARCHS: arm64-v8a
run: bun run build:android:fast
- name: Build signed release APK for GitHub Release
if: github.event_name == 'workflow_dispatch'
env:
@@ -215,15 +242,13 @@ jobs:
- name: Verify release APK signature
if: github.event_name == 'workflow_dispatch'
run: |
"$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner" verify --verbose app/build/outputs/apk/release/app-release.apk
working-directory: apps/mobile/android
run: node scripts/verify-android-apk-signature.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
- name: Upload APK
if: github.event_name == 'push'
uses: actions/upload-artifact@v7
with:
name: edgeever-android-test-release-apk
name: edgeever-android-main-apk
path: apps/mobile/android/app/build/outputs/apk/release/*.apk
if-no-files-found: error
compression-level: 0
@@ -239,5 +264,5 @@ jobs:
gh release upload "$RELEASE_TAG" "$release_asset" --repo "$GITHUB_REPOSITORY" --clobber
- name: Remove temporary upload keystore
if: always() && github.event_name == 'workflow_dispatch'
if: always()
run: rm -f "$RUNNER_TEMP/edgeever-upload.p12"
+36 -14
View File
@@ -1,6 +1,6 @@
# EdgeEver Mobile Builds
EdgeEver Mobile is built with Expo and React Native. Daily Android test packages are built directly on GitHub Actions, without using EAS Build quota.
EdgeEver Mobile is built with Expo and React Native. Main-branch Android packages are built directly on GitHub Actions, without using EAS Build quota.
## App Updates
@@ -15,22 +15,32 @@ bunx eas-cli update --channel production --environment production --message "Des
The update runtime uses the app-version policy. Any native dependency, Expo SDK, native module, or native configuration change requires incrementing `expo.version` and shipping a new store build before publishing code that depends on that native change. EAS Update does not replace Google Play or App Store updates for native binaries.
## Android Debug APK
## Main-Branch Android APK
The `Build EdgeEver Mobile` workflow runs on GitHub Actions and produces a debug APK artifact.
The `Build EdgeEver Mobile` workflow runs on GitHub Actions and produces a
fast arm64 APK after mobile changes land on `main`. This APK uses the same
pinned production signing certificate as formal GitHub Release APKs. The fast
build only disables R8, resource shrinking, and PNG crunching; it must never
fall back to `debug.keystore`.
It runs:
```sh
bun install --frozen-lockfile
bun run typecheck:mobile
cd apps/mobile
bunx expo prebuild --platform android --non-interactive --clean
cd android
./gradlew assembleDebug
bun run build:android:fast
```
The APK is uploaded as a GitHub Actions artifact named `edgeever-android-debug-apk`.
The APK is uploaded as a GitHub Actions artifact named
`edgeever-android-main-apk`. If the stable signing credentials are unavailable,
the workflow fails and does not upload an APK with a different signer.
For a local fast build, load the same signing environment used for release
builds:
```sh
bun run build:android:fast:local
```
## Release Builds
@@ -42,15 +52,27 @@ Every formal GitHub Release must include a directly installable APK. Build the p
bun run build:android:apk:local
```
This produces `apps/mobile/android/app/build/outputs/apk/release/app-release.apk`. Verify its application version, signer, and SHA-256 before uploading it to the matching GitHub Release. Additional ABIs should only be published for an explicit compatibility need; the Play AAB continues to include all supported architectures. A release whose audited change range affects mobile runtime code, shared code used by mobile, mobile dependencies, native configuration, or APK build tooling must rebuild the APK from that release commit.
This produces `apps/mobile/android/app/build/outputs/apk/release/app-release.apk`.
Before upload, automation requires exactly one signer and pins its certificate
SHA-256 to
`22bf52a9501c89020f5acc966960152c826bfa64f31e578e858d088f8cd75d87`.
Any other signer fails the build. Verify the application version and APK
SHA-256 as well. Additional ABIs should only be published for an explicit
compatibility need; the Play AAB continues to include all supported
architectures. A release whose audited change range affects mobile runtime
code, shared code used by mobile, mobile dependencies, native configuration,
APK build tooling, or signer verification must rebuild the APK from that
release commit.
If the audited change range does not affect the mobile binary, the most recent compatible, verified APK may be attached again without rebuilding. Keep its original versioned filename and checksum, and state the source release explicitly; never rename an older binary to the current release version.
The release workflow performs this audit automatically. Web-only releases use
an Ubuntu planning job and copy the single verified `arm64-v8a` APK from the
previous formal Release. The self-hosted Android runner is scheduled only when
the audited range includes mobile runtime code, shared mobile packages,
dependencies, native configuration, or Android build tooling.
The release workflow performs this audit automatically and verifies the pinned
signer for both newly built and reused APKs. Web-only releases use an Ubuntu
planning job and copy the single verified `arm64-v8a` APK from the previous
formal Release. The self-hosted Android runner is scheduled only when the
audited range includes mobile runtime code, shared mobile packages,
dependencies, native configuration, Android build tooling, or signer
verification.
### Recommended local Play build
+2 -1
View File
@@ -21,6 +21,7 @@
"dev:mobile": "bun --cwd apps/mobile expo start",
"prepare:mobile:icons": "bun scripts/prepare-mobile-icons.mjs",
"build:android:fast": "bash scripts/build-android-local.sh fast",
"build:android:fast:local": "bun --env-file=${EDGE_EVER_ANDROID_ENV_FILE:-$HOME/.config/edgeever/android/signing.env} run build:android:fast",
"build:android:apk": "bash scripts/build-android-local.sh apk",
"build:android:apk:local": "bun --env-file=${EDGE_EVER_ANDROID_ENV_FILE:-$HOME/.config/edgeever/android/signing.env} run build:android:apk",
"build:android:play": "bash scripts/build-android-local.sh play",
@@ -38,7 +39,7 @@
"build:desktop": "EDGE_EVER_DESKTOP_BUILD=1 bun run build:web && bun run build:desktop:sidecar && bun run --cwd apps/desktop dist",
"benchmark:desktop:sidecar": "bun scripts/benchmark-desktop-sidecar.mjs",
"verify:web-performance": "bun scripts/verify-web-performance.mjs",
"test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts",
"test:release-planning": "bun test scripts/plan-native-release.test.mjs scripts/check-native-release-assets.test.mjs scripts/create-mac-update-metadata.test.mjs scripts/release.test.mjs scripts/validate-store-delivery.test.mjs scripts/store-delivery.test.mjs scripts/verify-android-apk-signature.test.mjs apps/web/src/lib/version-check.test.mjs apps/mobile/src/lib/mobile-release.test.ts",
"release": "bun scripts/release.mjs",
"publish:stores": "bun scripts/store-delivery.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
+27 -19
View File
@@ -77,22 +77,6 @@ COMMON_ARGS=(
-Dorg.gradle.jvmargs=-Xmx6g\ -XX:MaxMetaspaceSize=1g\ -Dfile.encoding=UTF-8
)
if [[ "$MODE" == "fast" ]]; then
echo "构建 arm64 真机测试 Release APK(关闭 R8、资源压缩和 PNG crunch)..."
run_timed "Gradle 快速 APK 构建" ./gradlew assembleRelease \
"${COMMON_ARGS[@]}" \
-PreactNativeArchitectures=arm64-v8a \
-Pandroid.enableMinifyInReleaseBuilds=false \
-Pandroid.enableShrinkResourcesInReleaseBuilds=false \
-Pandroid.enablePngCrunchInReleaseBuilds=false \
-Pandroid.injected.signing.store.file="$ANDROID_DIR/app/debug.keystore" \
-Pandroid.injected.signing.store.password=android \
-Pandroid.injected.signing.key.alias=androiddebugkey \
-Pandroid.injected.signing.key.password=android
echo "完成: $ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
exit 0
fi
: "${ANDROID_KEYSTORE_FILE:?请设置 ANDROID_KEYSTORE_FILE(本地上传密钥路径)}"
: "${ANDROID_KEYSTORE_PASSWORD:?请设置 ANDROID_KEYSTORE_PASSWORD}"
: "${ANDROID_KEY_ALIAS:?请设置 ANDROID_KEY_ALIAS}"
@@ -101,6 +85,30 @@ fi
PLAY_ARCHS="${EDGE_EVER_ANDROID_ARCHS:-armeabi-v7a,arm64-v8a,x86,x86_64}"
APK_ARCHS="${EDGE_EVER_ANDROID_APK_ARCHS:-arm64-v8a}"
KEYSTORE_FILE="$(cd "$(dirname "$ANDROID_KEYSTORE_FILE")" && pwd)/$(basename "$ANDROID_KEYSTORE_FILE")"
APK_PATH="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
APKSIGNER_PATH="$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner"
if [[ "$MODE" == "fast" ]]; then
echo "构建生产签名 arm64 快速 APK(关闭 R8、资源压缩和 PNG crunch)..."
run_timed "Gradle 快速 APK 构建" ./gradlew assembleRelease \
"${COMMON_ARGS[@]}" \
-PreactNativeArchitectures=arm64-v8a \
-Pandroid.enableMinifyInReleaseBuilds=false \
-Pandroid.enableShrinkResourcesInReleaseBuilds=false \
-Pandroid.enablePngCrunchInReleaseBuilds=false \
-Pandroid.injected.signing.store.file="$KEYSTORE_FILE" \
-Pandroid.injected.signing.store.password="$ANDROID_KEYSTORE_PASSWORD" \
-Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" \
-Pandroid.injected.signing.store.type=PKCS12
test -s "$APK_PATH"
test -x "$APKSIGNER_PATH"
run_timed "校验 APK 固定签名" \
node "$PROJECT_ROOT/scripts/verify-android-apk-signature.mjs" \
"$APK_PATH" "$APKSIGNER_PATH"
echo "完成: $APK_PATH"
exit 0
fi
if [[ "$MODE" == "apk" ]]; then
echo "构建生产签名 APK(${APK_ARCHS})..."
@@ -112,13 +120,13 @@ if [[ "$MODE" == "apk" ]]; then
-Pandroid.injected.signing.key.alias="$ANDROID_KEY_ALIAS" \
-Pandroid.injected.signing.key.password="$ANDROID_KEY_PASSWORD" \
-Pandroid.injected.signing.store.type=PKCS12
APK_PATH="$ANDROID_DIR/app/build/outputs/apk/release/app-release.apk"
APKSIGNER_PATH="$ANDROID_SDK_ROOT/build-tools/36.0.0/apksigner"
AAPT2_PATH="$ANDROID_SDK_ROOT/build-tools/36.0.0/aapt2"
test -s "$APK_PATH"
test -x "$APKSIGNER_PATH"
test -x "$AAPT2_PATH"
run_timed "校验 APK 签名" "$APKSIGNER_PATH" verify --verbose "$APK_PATH"
run_timed "校验 APK 固定签名" \
node "$PROJECT_ROOT/scripts/verify-android-apk-signature.mjs" \
"$APK_PATH" "$APKSIGNER_PATH"
run_timed "读取 APK 信息" bash -c '"$1" dump badging "$2" | sed -n "1p"' _ "$AAPT2_PATH" "$APK_PATH"
run_timed "计算 APK SHA-256" shasum -a 256 "$APK_PATH"
echo "完成: $APK_PATH"
+6 -1
View File
@@ -24,7 +24,12 @@ export const planNativeRelease = (platform, changedFiles) => {
const relevantFiles =
platform === "mobile"
? new Set(["bun.lock", "scripts/build-android-local.sh"])
? new Set([
".github/workflows/mobile-build.yml",
"bun.lock",
"scripts/build-android-local.sh",
"scripts/verify-android-apk-signature.mjs",
])
: new Set([
".github/workflows/desktop-build.yml",
"bun.lock",
+2
View File
@@ -19,6 +19,8 @@ describe("native release planning", () => {
"packages/shared/src/index.ts",
"bun.lock",
"scripts/build-android-local.sh",
"scripts/verify-android-apk-signature.mjs",
".github/workflows/mobile-build.yml",
];
expect(planNativeRelease("mobile", changedFiles)).toEqual({
rebuild: true,
+93
View File
@@ -0,0 +1,93 @@
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const EDGE_EVER_ANDROID_SIGNER_SHA256 =
"22bf52a9501c89020f5acc966960152c826bfa64f31e578e858d088f8cd75d87";
const normalizeFingerprint = (value) =>
value.toLowerCase().replaceAll(":", "").trim();
export const verifyAndroidSignerOutput = (output) => {
const signerDigests = [
...output.matchAll(
/Signer #\d+ certificate SHA-256 digest:\s*([0-9a-f:]+)/gi,
),
].map((match) => normalizeFingerprint(match[1]));
if (signerDigests.length !== 1) {
throw new Error(
`Expected exactly one Android signer, found ${signerDigests.length}.`,
);
}
if (signerDigests[0] !== EDGE_EVER_ANDROID_SIGNER_SHA256) {
throw new Error(
`Android signer mismatch: expected ${EDGE_EVER_ANDROID_SIGNER_SHA256}, received ${signerDigests[0]}.`,
);
}
return signerDigests[0];
};
const findApkSigner = (explicitPath) => {
if (explicitPath) return explicitPath;
const sdkRoot = process.env.ANDROID_SDK_ROOT || process.env.ANDROID_HOME;
if (!sdkRoot) {
throw new Error(
"ANDROID_SDK_ROOT or ANDROID_HOME is required to locate apksigner.",
);
}
const buildToolsRoot = join(sdkRoot, "build-tools");
const versions = readdirSync(buildToolsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort((left, right) =>
right.localeCompare(left, undefined, { numeric: true }),
);
for (const version of versions) {
const candidate = join(buildToolsRoot, version, "apksigner");
if (existsSync(candidate)) return candidate;
}
throw new Error(`Could not find apksigner under ${buildToolsRoot}.`);
};
const run = () => {
const [apkPath, explicitApkSigner] = process.argv.slice(2);
if (!apkPath) {
console.error(
"Usage: node scripts/verify-android-apk-signature.mjs <apk-path> [apksigner-path]",
);
process.exit(1);
}
const apkSigner = findApkSigner(explicitApkSigner);
const result = spawnSync(
apkSigner,
["verify", "--verbose", "--print-certs", apkPath],
{
encoding: "utf8",
},
);
const output = `${result.stdout || ""}\n${result.stderr || ""}`;
if (result.status !== 0) {
process.stderr.write(output);
throw new Error(`apksigner rejected ${apkPath}.`);
}
const signerSha256 = verifyAndroidSignerOutput(output);
process.stdout.write(`Android signer SHA-256: ${signerSha256}\n`);
};
if (
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
run();
}
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test";
import {
EDGE_EVER_ANDROID_SIGNER_SHA256,
verifyAndroidSignerOutput,
} from "./verify-android-apk-signature.mjs";
describe("Android APK signer verification", () => {
test("accepts the single pinned EdgeEver signer", () => {
expect(
verifyAndroidSignerOutput(
`Signer #1 certificate SHA-256 digest: ${EDGE_EVER_ANDROID_SIGNER_SHA256}`,
),
).toBe(EDGE_EVER_ANDROID_SIGNER_SHA256);
});
test("normalizes colon-separated uppercase fingerprints", () => {
const fingerprint = EDGE_EVER_ANDROID_SIGNER_SHA256.toUpperCase()
.match(/.{2}/g)
.join(":");
expect(
verifyAndroidSignerOutput(
`Signer #1 certificate SHA-256 digest: ${fingerprint}`,
),
).toBe(EDGE_EVER_ANDROID_SIGNER_SHA256);
});
test("rejects an APK signed by a different certificate", () => {
expect(() =>
verifyAndroidSignerOutput(
`Signer #1 certificate SHA-256 digest: ${"0".repeat(64)}`,
),
).toThrow("Android signer mismatch");
});
test("rejects missing or multiple signers", () => {
expect(() => verifyAndroidSignerOutput("Verified")).toThrow(
"Expected exactly one Android signer",
);
expect(() =>
verifyAndroidSignerOutput(
[
`Signer #1 certificate SHA-256 digest: ${EDGE_EVER_ANDROID_SIGNER_SHA256}`,
`Signer #2 certificate SHA-256 digest: ${EDGE_EVER_ANDROID_SIGNER_SHA256}`,
].join("\n"),
),
).toThrow("Expected exactly one Android signer");
});
});