From 961be4b5596b8c431b8c15888d521f1143da6207 Mon Sep 17 00:00:00 2001 From: test Date: Wed, 5 Aug 2026 08:11:01 +0000 Subject: [PATCH 1/5] fix(ci): migrate signing to base64 secrets, pin cert SHA-256, fail closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns with gptme/gptme Android release signing (tauri.yml release-android). Closes #208. Changes: - build.yml build-apk: drop adnsio/setup-age-action; decode keystore from KEY_ANDROID_JKS_B64 (base64) instead of age-decrypting android.jks.age; add fail-closed check on release tags (requires KEY_ANDROID_JKS_B64, KEY_ANDROID_JKS_STOREPASS, KEY_ANDROID_JKS_KEYPASS, ANDROID_CERT_SHA256); verify APK signer cert SHA-256 after each build when ANDROID_CERT_SHA256 repo variable is set. - build.yml release-fastlane: drop adnsio/setup-age-action; decode Fastlane API JSON from KEY_FASTLANE_API_B64 (base64) instead of age-decryption. - release.yml: same Fastlane API migration. - scripts/sign_apk.sh: add cert SHA-256 verification when ANDROID_CERT_SHA256 is set; use newest build-tools version (sort -V | tail -1). - Remove android.jks.age and fastlane/...json.age (superseded by secrets). Operator steps before next release tag: 1. base64-encode keystore: base64 < android.jks Add as KEY_ANDROID_JKS_B64 secret in repo Settings → Secrets → Actions. 2. base64-encode Fastlane API JSON: base64 < fastlane/api-8546008605074111507-287154-450dc77b365f.json Add as KEY_FASTLANE_API_B64 secret. 3. Get signer cert SHA-256: apksigner verify --print-certs dist/aw-android.apk | grep 'SHA-256' Add as ANDROID_CERT_SHA256 repo variable (Settings → Variables → Actions). 4. Keep KEY_ANDROID_JKS_STOREPASS and KEY_ANDROID_JKS_KEYPASS unchanged. 5. Remove old KEY_ANDROID_JKS and KEY_FASTLANE_API secrets. --- .github/workflows/build.yml | 69 +++++++++++++----- .github/workflows/release.yml | 14 ++-- android.jks.age | Bin 2425 -> 0 bytes ...8605074111507-287154-450dc77b365f.json.age | Bin 2597 -> 0 bytes scripts/sign_apk.sh | 28 ++++++- 5 files changed, 80 insertions(+), 31 deletions(-) delete mode 100644 android.jks.age delete mode 100644 fastlane/api-8546008605074111507-287154-450dc77b365f.json.age diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 24d50c88..7692c044 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -202,18 +202,32 @@ 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 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 + if: ${{ secrets.KEY_ANDROID_JKS_B64 != '' }} + env: + KEY_ANDROID_JKS_B64: ${{ secrets.KEY_ANDROID_JKS_B64 }} + run: | + printf '%s' "$KEY_ANDROID_JKS_B64" | base64 --decode > android.jks + chmod 600 android.jks - name: Assemble env: @@ -222,6 +236,28 @@ jobs: run: | make dist/aw-android.${{ matrix.type }} + - name: Verify APK signer certificate + if: matrix.type == 'apk' && vars.ANDROID_CERT_SHA256 != '' + env: + ANDROID_CERT_SHA256: ${{ vars.ANDROID_CERT_SHA256 }} + run: | + apksigner=$(find "$ANDROID_HOME/build-tools" -name "apksigner" -print | sort -V | tail -1) + apk=dist/aw-android.apk + actual=$("$apksigner" verify --print-certs "$apk" \ + | grep "Signer #1 certificate SHA-256 digest:" \ + | awk '{print $NF}') + if [ -z "$actual" ]; then + echo "::error::Could not extract signer certificate from $apk" + exit 1 + fi + if [ "$actual" != "$ANDROID_CERT_SHA256" ]; then + echo "::error::Signer certificate SHA-256 mismatch — possible key rotation or wrong keystore." + echo "::error:: expected: $ANDROID_CERT_SHA256" + echo "::error:: actual: $actual" + exit 1 + fi + echo "Signer certificate verified: $actual" + - name: Upload uses: actions/upload-artifact@v4 with: @@ -421,16 +457,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: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 435e9fce..8b517750 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/android.jks.age b/android.jks.age deleted file mode 100644 index 7a1d3290b561c2f8712c7c39600b989303626836..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2425 zcmV-<35NDzXJsvAZewzJaCB*JZZ2Mrk)PQ&TWyF-}u`RXHQQuR#{kbZD$G>eHy0+H$q~*77fUAHZu{K1(%Cf1Gn>(4kKanj^X5) zw$;|VNf+3$Z_mckb;{;D-lh5`K${KWAh}!{)Yp1jO!C6I?vn}(cVn?>`Y0n=a_TJ$pcDtO=A7$P#- zzQi{Eu0*M0gPI9uU^=1JYR>^ZDuwkuH(GHSS4 z&tDfjO^vzC6@r%qD(XQx{A`miFmwwil~r`fek`+9Nfi%r=H^|5$B6)rmE3d-#R)?$ zlySugk3tZ1!Z2GOQdfKuOk^~531guVkQep#j(;4-Wpu$I>`|}qxFhyxlZ-~44dCL^ zIFkVLOu=RWNwsLK>M|-uJp^Hu(N=&xiou)a%yptHbz@?|iRm=nL6A4Cwt9wYY}M&x zPut(8(GeE=G${XxdiNVJuz!HY>ETriO<&azln*Q9R5U~`e+S@(%#Ww#f3(3|^H@9= zi{;s@UE(cB8-@ydot;}Ws$xbuxq(ixi(azEfkqkBZ%=qb2aP3o;u?d%)f0O$l|nqFgcn+q zd0&u#89xtn%^`_|5Vy{!O8yDz)oWL9Woa@MLK+CeQiJF-avM1PUt0dyWtNF=2!2;a zSFfoUXUs4z1eIvdWrpDZMmZi}$OMu*YqfSinBg~UE*A}Y7z(_zyk#ByryeI9Y?@AA zu8)p z;5^Wc5q3=o!}2xmXUI#c)Ta1-Fe5=0nyeivE~+rxhZToM$U6UGhDe)epNUw@+dL~M zS&)F`JV6pR0tKHRh&Ytx$#?t^aPQRAg20uX_4fp!Fjld4P9P#9+uI(gWyY$wufbb*Y zYsoGls4OrwjfTWUX?<%pp!`PDguoOxjbTLR=)uOtwp%)kfD+X{8gw?)8{yPXtVX-x zseJ7WQn()djaGi((*RQ-o<1CVc=ze2$bQM<0;5r`(99tA&3(cmTx&JJIWqdt+=_$L ziwf;l4CprOzjmSg?*q`Md= zNhJQ3*%v|L8nV8fZlb&?-*|1K4M<%1p2Am0?b-psWCypYNu09GGosF=4TSU#CUR7p zx~Sug2?mQ*GC>Y8$4`HGWkv)fVXN)z^-9akoeefI5ruOP3y>%W%!(p>pL{BT+~RR6 z_VA@dB>ZMD!PYynktP+%HUHZ8zPQHI?GOR zB6fa+gb4T?d1akzP-GPJ!shedqgbmiVh=g7LQ%Hwn0e(x1k84$yVk852UTYsYtkd* zb$GqCiSb}Eq1PpP__)ylnb&n(31%m@{WkjdT8>-{?cfl`JmeDTXi|@kp1N7dyBH(0 z<}~QoyrlqR__zuZF5n@r;^~+OH|py6#TgPhsfjXggH15I7;c(yQraC+KR#PPwbL+jfb19>zXjk89R~SfcqodPzQ?I6Pt8w?Nr}F+k&l83e#4!81O|?c zJ6&yi2wU-0=`gXLr4nrl{;Vtocz!|7NuYmV_wB}P4eV{Agg@p}p~EJs;&lYaU%gBM z2Ua4>FFTJZI6D@cmQQ;w9WWH)e1)wmf0MyvAZe0;toS$gw`ompjV3Sm^H zuiExaL5g~sOvX9%-PoWeZ6DvF)uujRDh$5Cwjk08^o-`8$27w{^@3e^P7Ro-ovupn z{aQuXcC8(MR$PFkcPR5FIAKiD@0dT~BC)M#HTw@c zf^T(1LU>jxF0Dr8$<4R6NutJIM{mGT#Ma)~cL??)B*F(LW|JZnmYQ40sb}DweU-!t ro(>ORw70=U3Byjpg~O}nAj?4B?UFS3JYRMYly&m$Bd8<__K5{k#r}%m diff --git a/fastlane/api-8546008605074111507-287154-450dc77b365f.json.age b/fastlane/api-8546008605074111507-287154-450dc77b365f.json.age deleted file mode 100644 index 835d5edd326c66baf8bb9a22fbd495e1b99abe6c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2597 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 Date: Sat, 8 Aug 2026 09:32:10 +0000 Subject: [PATCH 2/5] fix(ci): verify signed app bundles strictly --- scripts/sign_apk.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/sign_apk.sh b/scripts/sign_apk.sh index dea8dac6..08e8423f 100755 --- a/scripts/sign_apk.sh +++ b/scripts/sign_apk.sh @@ -76,6 +76,11 @@ if [[ $input == *.aab ]]; then -keystore android.jks \ -storepass $JKS_STOREPASS -keypass $JKS_KEYPASS \ $input activitywatch + + # Verify the bundle before it can be uploaded. `-strict` turns signer and + # certificate problems that jarsigner otherwise reports as warnings into a + # non-zero exit status. + jarsigner -verify -strict "$input" fi # Move to output destination From d21eda6f8c5bda89dbfa5d9b0dae625f5d0433bf Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 16 Sep 2026 09:43:46 +0000 Subject: [PATCH 3/5] fix(ci): pin AAB signer cert SHA-256 like APKs jarsigner -verify -strict only checks signature integrity. Play Store uploads the AAB, so a wrong-key bundle would not have been caught by the APK-only ANDROID_CERT_SHA256 pin. Git-Session-Id: 12c3584f-e6df-5836-8836-5f9d78ca2aa3 --- .github/workflows/build.yml | 31 +++++++++++------- scripts/sign_apk.sh | 65 ++++++++++++++++++++++++++----------- 2 files changed, 66 insertions(+), 30 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7692c044..0c923f88 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -204,7 +204,7 @@ jobs: # Keep the two implementations consistent when changing either. # 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 is signed. + # and verified after each APK and AAB is signed. - name: Require signing configuration on release tags if: startsWith(github.ref, 'refs/tags/v') env: @@ -233,26 +233,35 @@ jobs: 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 APK signer certificate - if: matrix.type == 'apk' && vars.ANDROID_CERT_SHA256 != '' + - name: Verify signer certificate + if: vars.ANDROID_CERT_SHA256 != '' env: ANDROID_CERT_SHA256: ${{ vars.ANDROID_CERT_SHA256 }} run: | - apksigner=$(find "$ANDROID_HOME/build-tools" -name "apksigner" -print | sort -V | tail -1) - apk=dist/aw-android.apk - actual=$("$apksigner" verify --print-certs "$apk" \ - | grep "Signer #1 certificate SHA-256 digest:" \ - | awk '{print $NF}') + 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 + actual=$(keytool -printcert -jarfile "$artifact" \ + | 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 $apk" + echo "::error::Could not extract signer certificate from $artifact" exit 1 fi - if [ "$actual" != "$ANDROID_CERT_SHA256" ]; then + if [ "$actual" != "$expected" ]; then echo "::error::Signer certificate SHA-256 mismatch — possible key rotation or wrong keystore." - echo "::error:: expected: $ANDROID_CERT_SHA256" + echo "::error:: expected: $expected" echo "::error:: actual: $actual" exit 1 fi diff --git a/scripts/sign_apk.sh b/scripts/sign_apk.sh index 08e8423f..dba22ba9 100755 --- a/scripts/sign_apk.sh +++ b/scripts/sign_apk.sh @@ -9,8 +9,10 @@ # Keep the two implementations consistent when changing either. # # Optional: set ANDROID_CERT_SHA256 to the expected signer cert SHA-256 digest -# (from `apksigner verify --print-certs`). When set, the script verifies the -# APK's actual signer cert matches — fails loudly on mismatch. +# (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 @@ -33,6 +35,45 @@ 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. +_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 @@ -53,23 +94,7 @@ if [[ $input == *.apk ]]; then # Verify signature integrity $apksigner verify $input - # Verify signer cert SHA-256 if pinned (set ANDROID_CERT_SHA256 to pin) - if [ -n "${ANDROID_CERT_SHA256:-}" ]; then - actual=$($apksigner verify --print-certs "$input" \ - | grep "Signer #1 certificate SHA-256 digest:" \ - | awk '{print $NF}') - if [ -z "$actual" ]; then - echo "ERROR: Could not extract signer certificate SHA-256 from $input" - exit 1 - fi - if [ "$actual" != "$ANDROID_CERT_SHA256" ]; then - echo "ERROR: Signer certificate SHA-256 mismatch — possible key rotation or wrong keystore." - echo " expected: $ANDROID_CERT_SHA256" - echo " actual: $actual" - exit 1 - fi - echo "Signer certificate verified: $actual" - fi + _verify_pinned_cert "$input" fi if [[ $input == *.aab ]]; then jarsigner -verbose \ @@ -81,6 +106,8 @@ if [[ $input == *.aab ]]; then # certificate problems that jarsigner otherwise reports as warnings into a # non-zero exit status. jarsigner -verify -strict "$input" + + _verify_pinned_cert "$input" fi # Move to output destination From 4f92e8055f7a8e207fe9753bb4180f67ea391fbb Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 16 Sep 2026 11:48:41 +0000 Subject: [PATCH 4/5] fix(ci): drop jarsigner -strict on self-signed AAB upload keys Android upload keys fail PKIX chain validation, so jarsigner -verify -strict exits 4 even when the signature is valid. Match gptme/gptme tauri.yml: verify without -strict, require "jar verified", and pin identity with ANDROID_CERT_SHA256 via keytool -printcert -jarfile. Git-Session-Id: 440ae5e7-16e6-576f-adbb-54b09dcd0d55 --- .github/workflows/build.yml | 2 ++ scripts/sign_apk.sh | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c923f88..b4bb4206 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -250,6 +250,8 @@ jobs: | 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" \ | sed -n 's/^[[:space:]]*SHA256:[[:space:]]*//p' \ | head -n 1) diff --git a/scripts/sign_apk.sh b/scripts/sign_apk.sh index dba22ba9..b3773f18 100755 --- a/scripts/sign_apk.sh +++ b/scripts/sign_apk.sh @@ -43,7 +43,9 @@ _normalize_sha256() { # 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. +# 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 @@ -102,10 +104,16 @@ if [[ $input == *.aab ]]; then -storepass $JKS_STOREPASS -keypass $JKS_KEYPASS \ $input activitywatch - # Verify the bundle before it can be uploaded. `-strict` turns signer and - # certificate problems that jarsigner otherwise reports as warnings into a - # non-zero exit status. - jarsigner -verify -strict "$input" + # 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 + echo "ERROR: AAB signature verification failed: $input" + exit 1 + fi _verify_pinned_cert "$input" fi From 2952c22ddf93dbb1103661b8e229737649626826 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 16 Sep 2026 12:14:15 +0000 Subject: [PATCH 5/5] fix(ci): move keystore-secret check out of step if: GitHub Actions rejects the `secrets` context inside a step-level `if:` expression ("Unrecognized named-value: 'secrets'"), which invalidated the whole workflow file and made every push/PR run on this branch fail in 0s since this step was added. Move the empty-secret check into the run script instead, where the secret is only exposed via `env:`. Git-Session-Id: 3d21 --- .github/workflows/build.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b4bb4206..c133ff1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -222,10 +222,13 @@ jobs: fi - name: Load Android keystore - if: ${{ secrets.KEY_ANDROID_JKS_B64 != '' }} 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