diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 951d3a9b8..7b8e55d6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -66,6 +66,18 @@ jobs: # The pull request as it would be once merged onto main is what gets built. When it cannot # be merged, the conflicting files and the conflicting hunks go into the run summary and the # pull-request comment, and the job stops here: there is nothing coherent to build. + # DroidDeck's signing key lives only in GitHub environment secrets (see sign.yml). A key file + # committed by mistake would be in the history for good: stop at the first one. The AOSP + # testkey is public and meant to be here. + - name: No private key in the tree + run: | + set -euo pipefail + keys=$(git ls-files | grep -iE '\.(jks|keystore|p12|pfx|pk8|pem|key)$' | grep -vx 'keystore/testkey.p12' || true) + if [ -n "$keys" ]; then + echo "::error::a private key is committed - remove it from the history and make a new one: $keys" + exit 1 + fi + - name: Merge onto main, or say what conflicts if: github.event_name == 'pull_request_target' run: | @@ -480,3 +492,15 @@ jobs: retention-days: 30 # An apk is a zip already; compressing it again buys nothing and costs seconds. compression-level: 0 + + # The apk signed with DroidDeck's own key (tools/release), so a test build installs over a release + # and keeps its data. Main's builds sign straight away (signing-main); any other branch's wait for + # the owner's approval (signing-branch). A pull request's never: its code is not ours to sign, and + # the job that would hold the key never runs for it. The testkey apk above is unaffected. + sign: + needs: build + if: github.event_name != 'pull_request_target' + uses: ./.github/workflows/sign.yml + with: + environment: ${{ github.ref == 'refs/heads/main' && 'signing-main' || 'signing-branch' }} + variants: '["standard"]' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..7f2307f73 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: Release APKs +run-name: release ${{ inputs.version }} from main ${{ github.sha }} + +# Every package of a release, signed with DroidDeck's own key: the standard app and the named ones +# in tools/release/variants.txt, one matrix job each. Nothing is rebuilt - the apk is the one the +# Build APK run of this exact main commit made (with its caches) and that was tested, so the +# release is those bytes. Waits for the owner's approval (the release environment); publishing the +# GitHub release stays a separate, manual step. + +on: + workflow_dispatch: + inputs: + version: + description: Version for the file names, e.g. 0.1.7 + required: true + +permissions: + contents: read + actions: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + find: + name: find the main build + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + outputs: + run_id: ${{ steps.find.outputs.run_id }} + variants: ${{ steps.find.outputs.variants }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - id: find + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + [[ $VERSION =~ ^[0-9A-Za-z._-]+$ ]] || { echo "::error::version '$VERSION': letters, digits, . _ - only"; exit 1; } + # This commit's Build APK run - a push or a dispatch on main, never a pull request's. + runs=$(gh run list -R "$GITHUB_REPOSITORY" --workflow build.yml --branch main --commit "$GITHUB_SHA" \ + --status success --json databaseId,event,headSha -L 20) + id=$(jq -r --arg sha "$GITHUB_SHA" \ + '[.[] | select(.headSha == $sha and (.event == "push" or .event == "workflow_dispatch"))][0].databaseId // empty' <<<"$runs") + if [ -z "$id" ]; then + echo "::error::main ${GITHUB_SHA::7} has no successful Build APK run yet: wait for it, then release" + exit 1 + fi + variants=$(awk '!/^#/ && NF { print $1 }' tools/release/variants.txt | jq -R . | jq -sc .) + echo "run_id=$id" >> "$GITHUB_OUTPUT" + echo "variants=$variants" >> "$GITHUB_OUTPUT" + echo "Signing Build APK run $id (main ${GITHUB_SHA::7}) as $VERSION: $variants" >> "$GITHUB_STEP_SUMMARY" + + sign: + needs: find + uses: ./.github/workflows/sign.yml + with: + environment: release + variants: ${{ needs.find.outputs.variants }} + run_id: ${{ needs.find.outputs.run_id }} + label: ${{ inputs.version }} + artifact: droiddeck-release diff --git a/.github/workflows/sign.yml b/.github/workflows/sign.yml new file mode 100644 index 000000000..ae70270fc --- /dev/null +++ b/.github/workflows/sign.yml @@ -0,0 +1,104 @@ +name: Sign APKs + +# Called, never run by itself: turns a "Build APK" apk into packages signed with DroidDeck's own key, +# one matrix job per package in tools/release/variants.txt (tools/release/sign-apk.sh does the work +# and checks it). The key lives only in the GitHub environment named by the caller: +# signing-main builds of main +# signing-branch builds of any other branch - waits for the owner's approval +# release release.yml - main only, waits for the owner's approval +# Pull requests never get here (build.yml skips this for pull_request_target), so code from a pull +# request can never be signed with the key or come near it. + +on: + workflow_call: + inputs: + environment: + type: string + required: true + variants: + description: JSON array of names from tools/release/variants.txt + type: string + required: true + run_id: + description: The Build APK run whose apk to sign; empty for the calling run's own + type: string + default: '' + label: + description: Goes in the file names (a version); empty for - + type: string + default: '' + artifact: + type: string + default: droiddeck-signed + +permissions: + contents: read + actions: read + +jobs: + sign: + name: sign ${{ matrix.variant }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: ${{ inputs.environment }} + strategy: + fail-fast: false + matrix: + variant: ${{ fromJSON(inputs.variants) }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: The apk (this run's build) + if: inputs.run_id == '' + uses: actions/download-artifact@v4 + with: + name: droiddeck-apk + path: in + + - name: The apk (a main build) + if: inputs.run_id != '' + env: + GH_TOKEN: ${{ github.token }} + RUN: ${{ inputs.run_id }} + run: gh run download "$RUN" -R "$GITHUB_REPOSITORY" -n droiddeck-apk -D in + + - name: Sign + env: + RELEASE_KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_B64 }} + RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + ENVIRONMENT: ${{ inputs.environment }} + VARIANT: ${{ matrix.variant }} + LABEL: ${{ inputs.label }} + run: | + set -euo pipefail + if [ -z "$RELEASE_KEYSTORE_B64" ]; then + echo "::error::environment '$ENVIRONMENT' has no signing key: run tools/release/make-release-key.sh once" + exit 1 + fi + export RELEASE_KEYSTORE="$RUNNER_TEMP/release.p12" + ( umask 077; base64 -d <<<"$RELEASE_KEYSTORE_B64" > "$RELEASE_KEYSTORE" ) + BUILD_TOOLS=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -n1) + export BUILD_TOOLS + [ -n "$LABEL" ] || LABEL="${GITHUB_REF_NAME//\//-}-${GITHUB_SHA::7}" + suffix=$(awk -v v="$VARIANT" '!/^#/ && $1 == v { print $3 }' tools/release/variants.txt) + [ "$suffix" = - ] && suffix= + apks=(in/*.apk) + [ ${#apks[@]} = 1 ] && [ -f "${apks[0]}" ] || { echo "::error::expected one apk in the build"; exit 1; } + mkdir -p out + out="out/DroidDeck-$LABEL$suffix.apk" + tools/release/sign-apk.sh "${apks[0]}" "$VARIANT" "$out" + ( cd out && sha256sum -- *.apk > "SHA256SUMS-$VARIANT.txt" && cat "SHA256SUMS-$VARIANT.txt" ) + echo "- \`$(basename "$out")\` - $(cut -c1-16 "out/SHA256SUMS-$VARIANT.txt")…" >> "$GITHUB_STEP_SUMMARY" + + - name: Forget the key + if: always() + run: rm -f "$RUNNER_TEMP/release.p12" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact }}-${{ matrix.variant }} + path: out/* + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 97ca1e4c8..1fa2e10c1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,15 @@ app/src/main/assets/linuxfs/ # Kotlin compiler session files (build-tool state, never source). .kotlin/ + +# Signing keys never enter the repo (DroidDeck's lives in GitHub environment secrets; see +# tools/release/make-release-key.sh). keystore/testkey.p12 is the public AOSP testkey. +*.jks +*.keystore +*.p12 +*.pfx +*.pk8 +*.pem +!keystore/testkey.p12 +droiddeck-release-key/ +password.txt diff --git a/tools/release/axml_rename.py b/tools/release/axml_rename.py new file mode 100644 index 000000000..ec553a12c --- /dev/null +++ b/tools/release/axml_rename.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# axml_rename.py in.apk out.apk old=new ... : rewrite whole strings in AndroidManifest.xml's string +# pool (binary XML), leaving every other entry of the apk byte-identical. Unsigned output. +import struct, sys, zipfile + +def dec_len8(b, o): + n = b[o]; o += 1 + if n & 0x80: n = ((n & 0x7f) << 8) | b[o]; o += 1 + return n, o + +def dec_len16(b, o): + n = struct.unpack_from('> 8), n & 0xff]) + +def enc_len16(n): + return struct.pack('> 16), n & 0xffff) + +def rewrite(axml, repl): + ftype, fhs, fsize = struct.unpack_from('&2; exit 1; } +command -v keytool >/dev/null || die "keytool not found (Termux: pkg install openjdk-17)" +command -v gh >/dev/null || die "gh not found" +gh auth status >/dev/null 2>&1 || die "gh is not logged in (gh auth login)" +[ -e "$OUT/droiddeck-release.p12" ] && die "a key already exists in $OUT - there must only ever be one" +case "$OUT" in "$repo"|"$repo"/*) die "the key must not be made inside the repo" ;; esac +for env in "${ENVIRONMENTS[@]}"; do + gh api "repos/$REPO/environments/$env" >/dev/null 2>&1 || die "GitHub environment '$env' does not exist yet" +done + +umask 077 +mkdir -p "$OUT" +KS="$OUT/droiddeck-release.p12" +# 48 random characters; PKCS12 uses one password for the store and the key. +KS_PW=$(head -c 60 /dev/urandom | base64 -w0 | tr -d '/+=') +KS_PW=${KS_PW:0:48} +[ ${#KS_PW} -ge 40 ] || die "could not make a password" +export KS_PW +printf '%s\n' "$KS_PW" > "$OUT/password.txt" + +keytool -genkeypair -keystore "$KS" -storetype PKCS12 -alias "$ALIAS" \ + -keyalg RSA -keysize 4096 -validity 10950 -dname "CN=DroidDeck, O=The412Banner" \ + -storepass:env KS_PW -keypass:env KS_PW >/dev/null 2>&1 || die "keytool could not make the key" +listing=$(keytool -list -v -keystore "$KS" -storepass:env KS_PW -alias "$ALIAS") +SHA=$(awk '/SHA256:/ { print $2 }' <<<"$listing" | head -n1 | tr -d ':' | tr 'A-F' 'a-f') +[[ $SHA =~ ^[0-9a-f]{64}$ ]] || die "could not read the key's certificate digest" + +for env in "${ENVIRONMENTS[@]}"; do + base64 -w0 < "$KS" | gh secret set RELEASE_KEYSTORE_B64 --env "$env" -R "$REPO" >/dev/null + printf '%s' "$KS_PW" | gh secret set RELEASE_STORE_PASSWORD --env "$env" -R "$REPO" >/dev/null + printf '%s' "$ALIAS" | gh secret set RELEASE_KEY_ALIAS --env "$env" -R "$REPO" >/dev/null + echo "secrets set for environment: $env" +done +unset KS_PW + +printf '%s\n' "$SHA" > "$repo/keystore/release-signer.sha256" +cat < +# +# Turns a CI build (signed with the public AOSP testkey) into one package of tools/release/variants.txt +# signed with DroidDeck's own key, carrying the hand-over from the testkey: +# - v1 + v2 are signed by the testkey, v3 by our key, with a signed lineage testkey -> our key; +# - an install from before (testkey) updates to it and keeps its data; +# - the testkey loses its rollback right, so an apk signed with the testkey alone - which anyone +# can make - is refused as an update to it (device-proven on the Pocket FIT, 2026-09-24); +# - --rotation-min-sdk-version 28: the lineage applies from Android 9, not only from 13. +# +# The key comes from the environment and is never written anywhere but the path given: +# RELEASE_KEYSTORE path to the PKCS12 keystore +# RELEASE_STORE_PASSWORD its password (the key has the same one: PKCS12) +# RELEASE_KEY_ALIAS the key's alias +# BUILD_TOOLS Android build-tools dir (zipalign, apksigner, aapt) +# RELEASE_SIGNER_SHA256 optional: the expected certificate digest, else keystore/release-signer.sha256 +set -euo pipefail + +die() { echo "::error::$*" >&2; exit 1; } +[ $# -eq 3 ] || die "usage: sign-apk.sh " +in=$1 variant=$2 out=$3 +here=$(cd "$(dirname "$0")" && pwd) +repo=$(cd "$here/../.." && pwd) + +pkg=$(awk -v v="$variant" '!/^#/ && $1 == v { print $2 }' "$here/variants.txt") +[ -n "$pkg" ] || die "no variant '$variant' in tools/release/variants.txt" +expected=${RELEASE_SIGNER_SHA256:-$(tr -d ' \n' < "$repo/keystore/release-signer.sha256" 2>/dev/null || true)} +[[ $expected =~ ^[0-9a-f]{64}$ ]] \ + || die "keystore/release-signer.sha256 is missing: make the key first (tools/release/make-release-key.sh)" +: "${RELEASE_KEYSTORE:?}" "${RELEASE_STORE_PASSWORD:?}" "${RELEASE_KEY_ALIAS:?}" "${BUILD_TOOLS:?}" +[ -s "$RELEASE_KEYSTORE" ] || die "the release keystore is empty - is the secret set for this environment?" + +TESTKEY="$repo/keystore/testkey.p12" +TESTKEY_SHA256=a40da80a59d170caa950cf15c18c454d47a39b26989d8b640ecd745ba71bf5dc +export TESTKEY_PASSWORD=android +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# The package name and everything the manifest derives from it; component class names stay. +P=com.droiddeck.launcher +if [ "$pkg" = "$P" ]; then + python3 "$here/axml_rename.py" "$in" "$work/renamed.apk" +else + python3 "$here/axml_rename.py" "$in" "$work/renamed.apk" \ + "$P=$pkg" "$P.androidx-startup=$pkg.androidx-startup" "$P.logs=$pkg.logs" \ + "$P.documents=$pkg.documents" "$P.home=$pkg.home" \ + "$P.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION=$pkg.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" +fi +got=$("$BUILD_TOOLS/aapt" dump badging "$work/renamed.apk" | sed -n "s/^package: name='\([^']*\)'.*/\1/p") +[ "$got" = "$pkg" ] || die "$variant: the apk says package '$got', not '$pkg'" +if [ "$pkg" != "$P" ]; then + # A provider left on the old authority would clash with the standard app installed beside it. + tree=$("$BUILD_TOOLS/aapt" dump xmltree "$work/renamed.apk" AndroidManifest.xml) + grep -E 'android:(authorities|taskAffinity)' <<<"$tree" | grep -F "\"$P." \ + && die "$variant: the manifest still names $P in an authority or task" +fi + +"$BUILD_TOOLS/zipalign" -p -f 4 "$work/renamed.apk" "$work/aligned.apk" + +# The lineage: the testkey vouches for our key. Made fresh each time (it is two certificates and +# a signature, nothing secret beyond the key that signs it). +"$BUILD_TOOLS/apksigner" rotate --out "$work/lineage" \ + --old-signer --ks "$TESTKEY" --ks-type PKCS12 --ks-pass env:TESTKEY_PASSWORD \ + --ks-key-alias testkey --key-pass env:TESTKEY_PASSWORD --set-rollback false \ + --new-signer --ks "$RELEASE_KEYSTORE" --ks-type PKCS12 --ks-pass env:RELEASE_STORE_PASSWORD \ + --ks-key-alias "$RELEASE_KEY_ALIAS" --key-pass env:RELEASE_STORE_PASSWORD + +"$BUILD_TOOLS/apksigner" sign \ + --ks "$TESTKEY" --ks-type PKCS12 --ks-pass env:TESTKEY_PASSWORD \ + --ks-key-alias testkey --key-pass env:TESTKEY_PASSWORD \ + --next-signer --ks "$RELEASE_KEYSTORE" --ks-type PKCS12 --ks-pass env:RELEASE_STORE_PASSWORD \ + --ks-key-alias "$RELEASE_KEY_ALIAS" --key-pass env:RELEASE_STORE_PASSWORD \ + --lineage "$work/lineage" --rotation-min-sdk-version 28 \ + --v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true --v4-signing-enabled false \ + --out "$out" "$work/aligned.apk" +rm -f "$out.idsig" + +# What the apk must carry, checked rather than assumed. +"$BUILD_TOOLS/apksigner" verify --min-sdk-version 26 "$out" >/dev/null || die "$variant: does not verify" +# (Listed first, then searched: grep -q stops reading early, which fails a pipe under pipefail.) +listing=$(unzip -l "$out") +grep -qE 'META-INF/.*\.(SF|RSA)$' <<<"$listing" || die "$variant: no JAR signature (old installers)" +certs=$("$BUILD_TOOLS/apksigner" verify --min-sdk-version 28 --print-certs "$out") +v3=$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' <<<"$certs") +[ "$v3" = "$expected" ] || die "$variant: Android 9+ sees signer $v3, not DroidDeck's key $expected" +lineage=$("$BUILD_TOOLS/apksigner" lineage --in "$out" --print-certs) +grep -q "Signer #1 in lineage certificate SHA-256 digest: $TESTKEY_SHA256" <<<"$lineage" \ + || die "$variant: the lineage does not start at the testkey - old installs could not update" +grep -q "Signer #2 in lineage certificate SHA-256 digest: $expected" <<<"$lineage" \ + || die "$variant: the lineage does not hand over to DroidDeck's key" +rollback=$(awk '/Signer #1 in lineage/{s=1} /Signer #2 in lineage/{s=0} s && /rollback capability/' <<<"$lineage") +grep -q false <<<"$rollback" \ + || die "$variant: the testkey kept its rollback right - a testkey-signed apk could replace this one" +echo "$variant ($pkg): signed, hand-over from the testkey, v3 signer $expected" diff --git a/tools/release/variants.txt b/tools/release/variants.txt new file mode 100644 index 000000000..607106800 --- /dev/null +++ b/tools/release/variants.txt @@ -0,0 +1,9 @@ +# The packages a signed build ships as. The same apk, with only the manifest's package name (and +# the names derived from it) changed: some phones give their performance modes only to apps with +# certain names. Each is its own app to Android - installs beside the others, with its own data. +# +# name package file suffix +standard com.droiddeck.launcher - +pubg com.tencent.ig -pubg +antutu com.antutu.benchmark.full -antutu +ludashi com.ludashi.benchmark -ludashi