Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 65 additions & 18 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,26 +202,76 @@ jobs:
# release-android job + docs/contributing.rst "Android release signing"
# there); signing itself happens in scripts/sign_apk.sh via the Makefile.
# Keep the two implementations consistent when changing either.
# gptme delivers the keystore as a base64 secret instead of age-encrypted
# in-repo, pins the signer cert SHA-256, and fails closed on missing
# secrets — planned to be adopted here too (see issue tracker).
- uses: adnsio/setup-age-action@v1.2.0
- name: Load Android secrets
if: env.KEY_ANDROID_JKS != null
# Keystore is delivered as a base64 secret (KEY_ANDROID_JKS_B64) and decoded
# here; the signer cert SHA-256 is pinned in ANDROID_CERT_SHA256 (repo variable)
# and verified after each APK and AAB is signed.
- name: Require signing configuration on release tags
if: startsWith(github.ref, 'refs/tags/v')
env:
KEY_ANDROID_JKS: ${{ secrets.KEY_ANDROID_JKS }}
HAS_SIGNING: >-
${{ secrets.KEY_ANDROID_JKS_B64 != '' &&
secrets.KEY_ANDROID_JKS_STOREPASS != '' &&
secrets.KEY_ANDROID_JKS_KEYPASS != '' &&
vars.ANDROID_CERT_SHA256 != '' }}
run: |
printf "$KEY_ANDROID_JKS" > android.jks.key
cat android.jks.age | age -d -i android.jks.key -o android.jks
rm android.jks.key
if [ "$HAS_SIGNING" != "true" ]; then
echo "::error::Android release signing is not fully configured."
echo "::error::Provision KEY_ANDROID_JKS_B64, KEY_ANDROID_JKS_STOREPASS, KEY_ANDROID_JKS_KEYPASS"
echo "::error::secrets and ANDROID_CERT_SHA256 repo variable before cutting a release tag."
exit 1
fi

- name: Load Android keystore
Comment thread
TimeToBuildBob marked this conversation as resolved.
env:
KEY_ANDROID_JKS_B64: ${{ secrets.KEY_ANDROID_JKS_B64 }}
run: |
if [ -z "$KEY_ANDROID_JKS_B64" ]; then
echo "No keystore secret configured; skipping."
exit 0
fi
printf '%s' "$KEY_ANDROID_JKS_B64" | base64 --decode > android.jks
chmod 600 android.jks

- name: Assemble
env:
JKS_STOREPASS: ${{ secrets.KEY_ANDROID_JKS_STOREPASS }}
JKS_KEYPASS: ${{ secrets.KEY_ANDROID_JKS_KEYPASS }}
ANDROID_CERT_SHA256: ${{ vars.ANDROID_CERT_SHA256 }}
run: |
make dist/aw-android.${{ matrix.type }}

- name: Verify signer certificate
if: vars.ANDROID_CERT_SHA256 != ''
env:
ANDROID_CERT_SHA256: ${{ vars.ANDROID_CERT_SHA256 }}
run: |
artifact=dist/aw-android.${{ matrix.type }}
expected=$(printf '%s' "$ANDROID_CERT_SHA256" | tr -d ' :\n' | tr 'A-F' 'a-f')
if [ "${{ matrix.type }}" = "apk" ]; then
apksigner=$(find "$ANDROID_HOME/build-tools" -name "apksigner" -print | sort -V | tail -1)
actual=$("$apksigner" verify --print-certs "$artifact" \
| grep "Signer #1 certificate SHA-256 digest:" \
| awk '{print $NF}')
else
# jarsigner-signed AABs are JARs; keytool reads META-INF PKCS7
# (same as gptme/gptme .github/workflows/tauri.yml).
actual=$(keytool -printcert -jarfile "$artifact" \
Comment thread
TimeToBuildBob marked this conversation as resolved.
| sed -n 's/^[[:space:]]*SHA256:[[:space:]]*//p' \
| head -n 1)
fi
actual=$(printf '%s' "$actual" | tr -d ' :\n' | tr 'A-F' 'a-f')
if [ -z "$actual" ]; then
echo "::error::Could not extract signer certificate from $artifact"
exit 1
fi
if [ "$actual" != "$expected" ]; then
echo "::error::Signer certificate SHA-256 mismatch — possible key rotation or wrong keystore."
echo "::error:: expected: $expected"
echo "::error:: actual: $actual"
exit 1
fi
echo "Signer certificate verified: $actual"

- name: Upload
uses: actions/upload-artifact@v4
with:
Expand Down Expand Up @@ -421,16 +471,13 @@ jobs:
fi
echo "SUPPLY_TRACK=${SUPPLY_TRACK}" >> $GITHUB_ENV

- uses: adnsio/setup-age-action@v1.2.0
- name: Load Android secrets
- name: Load Fastlane API credentials
env:
KEY_FASTLANE_API: ${{ secrets.KEY_FASTLANE_API }}
KEY_FASTLANE_API_B64: ${{ secrets.KEY_FASTLANE_API_B64 }}
run: |
printf "$KEY_FASTLANE_API" > fastlane/api-8546008605074111507-287154-450dc77b365f.json.key
cat fastlane/api-8546008605074111507-287154-450dc77b365f.json.age \
| age -d -i fastlane/api-8546008605074111507-287154-450dc77b365f.json.key \
-o fastlane/api-8546008605074111507-287154-450dc77b365f.json
rm fastlane/api-8546008605074111507-287154-450dc77b365f.json.key
printf '%s' "$KEY_FASTLANE_API_B64" | base64 --decode \
> fastlane/api-8546008605074111507-287154-450dc77b365f.json
chmod 600 fastlane/api-8546008605074111507-287154-450dc77b365f.json

- name: Release with fastlane
run: |
Expand Down
14 changes: 5 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,13 @@ jobs:
with:
bundler-cache: true

- uses: adnsio/setup-age-action@v1.2.0

- name: Load Fastlane secrets
- name: Load Fastlane API credentials
env:
KEY_FASTLANE_API: ${{ secrets.KEY_FASTLANE_API }}
KEY_FASTLANE_API_B64: ${{ secrets.KEY_FASTLANE_API_B64 }}
run: |
printf "$KEY_FASTLANE_API" > fastlane/api-8546008605074111507-287154-450dc77b365f.json.key
cat fastlane/api-8546008605074111507-287154-450dc77b365f.json.age \
| age -d -i fastlane/api-8546008605074111507-287154-450dc77b365f.json.key \
-o fastlane/api-8546008605074111507-287154-450dc77b365f.json
rm fastlane/api-8546008605074111507-287154-450dc77b365f.json.key
printf '%s' "$KEY_FASTLANE_API_B64" | base64 --decode \
> fastlane/api-8546008605074111507-287154-450dc77b365f.json
chmod 600 fastlane/api-8546008605074111507-287154-450dc77b365f.json

- name: Update versionCode
uses: Wandalen/wretry.action@v3.8.0_js_action
Expand Down
Binary file removed android.jks.age
Binary file not shown.
Binary file not shown.
68 changes: 64 additions & 4 deletions scripts/sign_apk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@
# release signing (gptme/gptme .github/workflows/tauri.yml release-android job,
# documented in docs/contributing.rst "Android release signing" there).
# Keep the two implementations consistent when changing either.
# gptme additionally pins the signer cert SHA-256 and fails closed when
# signing secrets are missing — planned to be adopted here too.
#
# Optional: set ANDROID_CERT_SHA256 to the expected signer cert SHA-256 digest
# (from `apksigner verify --print-certs`, lowercase hex without colons). When
# set, the script verifies the signed APK *and* AAB signer cert matches —
# fails loudly on mismatch. keytool fingerprints (colon-separated uppercase)
# are normalized to the same form.

set -e

Expand All @@ -31,6 +35,47 @@ if [ -z $JKS_KEYPASS ]; then
exit 1
fi

# apksigner prints lowercase hex without colons; keytool prints uppercase
# colon-separated SHA256. Normalize both (and the pin) before comparing.
_normalize_sha256() {
printf '%s' "$1" | tr -d ' :\n' | tr 'A-F' 'a-f'
}

# Fail closed when ANDROID_CERT_SHA256 is set and the signed artifact's
# signer cert does not match. APKs use apksigner; AABs use keytool because
# apksigner does not support app bundles. jarsigner-signed AABs are JARs,
# so `keytool -printcert -jarfile` reads the META-INF PKCS7 signer cert
# (same command as gptme/gptme .github/workflows/tauri.yml).
_verify_pinned_cert() {
local file=$1
local actual expected
if [ -z "${ANDROID_CERT_SHA256:-}" ]; then
return 0
fi
if [[ $file == *.apk ]]; then
actual=$($apksigner verify --print-certs "$file" \
| grep "Signer #1 certificate SHA-256 digest:" \
| awk '{print $NF}')
else
actual=$(keytool -printcert -jarfile "$file" \
| sed -n 's/^[[:space:]]*SHA256:[[:space:]]*//p' \
| head -n 1)
fi
actual=$(_normalize_sha256 "$actual")
expected=$(_normalize_sha256 "$ANDROID_CERT_SHA256")
if [ -z "$actual" ]; then
echo "ERROR: Could not extract signer certificate SHA-256 from $file"
exit 1
fi
if [ "$actual" != "$expected" ]; then
echo "ERROR: Signer certificate SHA-256 mismatch — possible key rotation or wrong keystore."
echo " expected: $expected"
echo " actual: $actual"
exit 1
fi
echo "Signer certificate verified: $actual"
}

# Zipalign
# Not needed for AABs
if [[ $input == *.apk ]]; then
Expand All @@ -43,19 +88,34 @@ fi
# Using apksigner for APKs instead of jarsigner since API 30+: https://stackoverflow.com/a/69473649
# Using jarsigner for AABs since apksigner doesn't support them
if [[ $input == *.apk ]]; then
apksigner=$(find $ANDROID_HOME/build-tools -name "apksigner" -print | head -n 1)
apksigner=$(find $ANDROID_HOME/build-tools -name "apksigner" -print | sort -V | tail -n 1)
$apksigner sign --ks android.jks --ks-key-alias activitywatch \
--ks-pass env:JKS_STOREPASS --key-pass env:JKS_KEYPASS \
$input

# Verify
# Verify signature integrity
$apksigner verify $input

_verify_pinned_cert "$input"
fi
if [[ $input == *.aab ]]; then
jarsigner -verbose \
-keystore android.jks \
-storepass $JKS_STOREPASS -keypass $JKS_KEYPASS \
$input activitywatch

# Verify the bundle before it can be uploaded. Do not use -strict:
# Android upload keys are self-signed, so PKIX chain validation fails
# with exit 4 even when the signature is valid. Identity is enforced
# by the ANDROID_CERT_SHA256 pin below (same as gptme/gptme tauri.yml).
verify_out=$(jarsigner -verify "$input")
printf '%s\n' "$verify_out"
if ! printf '%s' "$verify_out" | grep -q "jar verified"; then
Comment thread
TimeToBuildBob marked this conversation as resolved.
echo "ERROR: AAB signature verification failed: $input"
exit 1
fi

_verify_pinned_cert "$input"
fi

# Move to output destination
Expand Down
Loading