soul-browser/.github/workflows/build-apk.yml
KaKi87 f58993e8be Skip APK builds for tags that are not on main.
Gate the build job with a decide-build check so only main tags and non-main branch pushes produce APKs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 19:27:47 +02:00

497 lines
19 KiB
YAML

name: Build APK
on:
push:
branches:
- '**'
tags:
- '**'
# Report-only / docs-only commits must not rebuild the APK.
# README.md is intentionally not ignored: prepare-info-string.py embeds it.
paths-ignore:
- 'exodus_privacy_report_v2_latest.txt'
- 'bundled-warp/lib/**'
- 'bundled-warp/README.md'
- 'prebuilts/**'
- 'app/res/xml/locales_config.xml'
- 'AGENTS.md'
- '.gitignore'
- 'LICENSE'
workflow_dispatch:
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
push-i18n-english:
# feature/push-i18n-english is the rollout branch for this job; remove it after merge to main.
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/feature/push-i18n-english'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Publish English strings to i18n repository
env:
I18N_GITHUB_TOKEN: ${{ secrets.I18N_GITHUB_TOKEN }}
run: |
set -euo pipefail
if [[ -z "${I18N_GITHUB_TOKEN}" ]]; then
echo "Missing repository secret I18N_GITHUB_TOKEN (repo-scoped PAT with write access to KaKi87/soul-browser-i18n)." >&2
exit 1
fi
python3 scripts/push-i18n-english.py
decide-build:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
build: ${{ steps.decide.outputs.build }}
on_main: ${{ steps.decide.outputs.on_main }}
steps:
- name: Decide whether to build an APK
id: decide
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
ref="${{ github.ref }}"
build=false
on_main=false
if [[ "${ref}" == "refs/heads/main" ]]; then
echo "Skip APK build on plain main push."
elif [[ "${ref}" == refs/heads/* ]]; then
# Feature/testing branches keep building on every push.
build=true
elif [[ "${ref}" == refs/tags/* ]]; then
compare_status="$(curl -fsS \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/compare/main...${GITHUB_SHA}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])')"
# identical = tag is main tip; behind = tag commit is an ancestor of main.
if [[ "${compare_status}" == "identical" || "${compare_status}" == "behind" ]]; then
build=true
on_main=true
else
echo "Skip APK build: tag ${{ github.ref_name }} is not on main (compare status=${compare_status})."
fi
else
echo "Skip APK build: unhandled ref ${ref}"
fi
echo "build=${build}" >> "${GITHUB_OUTPUT}"
echo "on_main=${on_main}" >> "${GITHUB_OUTPUT}"
build:
needs: decide-build
if: needs.decide-build.outputs.build == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set app version
env:
ON_MAIN: ${{ needs.decide-build.outputs.on_main }}
run: |
set -euo pipefail
VERSION="$(tr -d '[:space:]' < VERSION)"
# Accept MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-rc.N (N = 1..98).
if [[ ! "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-rc\.([0-9]+))?$ ]]; then
echo "VERSION must be MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-rc.N, got: ${VERSION}" >&2
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
RC="${BASH_REMATCH[5]:-}"
# Encode so X.Y.Z-rc.N < X.Y.Z, with room for patch/minor:
# MAJOR*1_000_000 + MINOR*10_000 + PATCH*100 + (RC or 99)
VERSION_CODE_BASE=$((MAJOR * 1000000 + MINOR * 10000 + PATCH * 100))
if [[ -n "$RC" ]]; then
if (( 10#${RC} < 1 || 10#${RC} > 98 )); then
echo "RC number must be between 1 and 98, got: ${RC}" >&2
exit 1
fi
VERSION_CODE_BASE=$((VERSION_CODE_BASE + 10#${RC}))
else
VERSION_CODE_BASE=$((VERSION_CODE_BASE + 99))
fi
if [[ "${ON_MAIN}" == "true" ]]; then
VERSION_NAME="$VERSION"
VERSION_CODE="$VERSION_CODE_BASE"
echo "SOUL_PACKAGE_ID=net.kaki87.soul2" >> "$GITHUB_ENV"
else
TESTING_VERSION="${{ github.run_number }}${{ github.run_attempt }}"
VERSION_NAME="${VERSION}-testing.${TESTING_VERSION}"
VERSION_CODE=$((VERSION_CODE_BASE + TESTING_VERSION))
echo "SOUL_PACKAGE_ID=net.kaki87.soul2.testing" >> "$GITHUB_ENV"
fi
echo "versionName=${VERSION_NAME}"
echo "versionCode=${VERSION_CODE}"
sed -i "s/^ versionCode:.*/ versionCode: ${VERSION_CODE}/" app/apktool.yml
sed -i "s/^ versionName:.*/ versionName: ${VERSION_NAME}/" app/apktool.yml
- name: Check if soulamz rebuild is needed
id: soulamz-build
run: |
set -euo pipefail
chmod +x scripts/soulamz-stamp.sh
NEW_HASH="$(./scripts/soulamz-stamp.sh hash)"
OLD_HASH="$(tr -d '[:space:]' < bundled-warp/soulamz.inputs 2>/dev/null || true)"
if [[ -f bundled-warp/lib/arm64-v8a/libsoulamz.so ]] && [[ "${NEW_HASH}" == "${OLD_HASH}" ]]; then
echo "needed=false" >> "${GITHUB_OUTPUT}"
echo "soulamz prebuilts up to date (${NEW_HASH})"
else
echo "needed=true" >> "${GITHUB_OUTPUT}"
echo "soulamz rebuild required (new=${NEW_HASH}, old=${OLD_HASH})"
fi
- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
# Default "tools" pulls Android Emulator; build only needs sdkmanager + build-tools.
packages: platform-tools
# Do not cache SDK packages via actions/cache: build-tools+platforms upload
# took ~7 minutes, while sdkmanager install is a few seconds.
- name: Install Android build-tools and platform
run: sdkmanager "build-tools;35.0.0" "platforms;android-35"
- name: Set up Go
if: steps.soulamz-build.outputs.needed == 'true'
uses: actions/setup-go@v5
with:
go-version-file: native/soulamz/go.mod
- name: Install Android NDK
if: steps.soulamz-build.outputs.needed == 'true'
run: sdkmanager "ndk;27.0.12077973"
- name: Resolve i18n commit
id: i18n
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
sha="$(
curl -fsSL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/KaKi87/soul-browser-i18n/commits/master" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["sha"])'
)"
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
- name: Cache i18n locales
uses: actions/cache@v4
with:
path: |
app/res/.i18n-sync
app/res/xml/locales_config.xml
app/res/values-*
key: i18n-${{ steps.i18n.outputs.sha }}-${{ hashFiles('app/res/values/strings.xml', 'scripts/sync-i18n.py') }}
- name: Prepare release signing keystore
env:
SOUL_KEYSTORE_BASE64: ${{ secrets.SOUL_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
if [[ -z "${SOUL_KEYSTORE_BASE64}" ]]; then
echo "Missing repository secret SOUL_KEYSTORE_BASE64 (base64-encoded PKCS12/JKS)." >&2
exit 1
fi
mkdir -p keystore
# Avoid echoing secret material; write decoded bytes only.
printf '%s' "${SOUL_KEYSTORE_BASE64}" | base64 -d > keystore/release.keystore
chmod 600 keystore/release.keystore
echo "SOUL_KEYSTORE=${GITHUB_WORKSPACE}/keystore/release.keystore" >> "${GITHUB_ENV}"
- name: Build APK
env:
SOUL_KEY_ALIAS: ${{ secrets.SOUL_KEY_ALIAS }}
SOUL_KEYSTORE_PASSWORD: ${{ secrets.SOUL_KEYSTORE_PASSWORD }}
SOUL_KEY_PASSWORD: ${{ secrets.SOUL_KEY_PASSWORD }}
run: |
set -euo pipefail
if [[ -z "${SOUL_KEYSTORE:-}" || -z "${SOUL_KEYSTORE_PASSWORD:-}" || -z "${SOUL_KEY_PASSWORD:-}" ]]; then
echo "Missing signing secrets (SOUL_KEYSTORE / SOUL_KEYSTORE_PASSWORD / SOUL_KEY_PASSWORD)." >&2
exit 1
fi
# Default alias matches local/debug builds when the secret is unset.
export SOUL_KEY_ALIAS="${SOUL_KEY_ALIAS:-soulbrowser}"
chmod +x scripts/build.sh
./scripts/build.sh
- name: Commit locales_config.xml
if: needs.decide-build.outputs.on_main == 'true'
run: |
set -euo pipefail
if [[ ! -f app/res/xml/locales_config.xml ]]; then
echo "error: locales_config.xml missing after build" >&2
exit 1
fi
if git cat-file -e "HEAD:app/res/xml/locales_config.xml" 2>/dev/null \
&& cmp -s app/res/xml/locales_config.xml <(git show HEAD:app/res/xml/locales_config.xml); then
echo "locales_config.xml unchanged"
exit 0
fi
locales_stash="/tmp/locales_config.xml"
cp -a app/res/xml/locales_config.xml "${locales_stash}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main
git reset --hard origin/main
cp -a "${locales_stash}" app/res/xml/locales_config.xml
git add -f app/res/xml/locales_config.xml
if git diff --cached --quiet; then
echo "locales_config.xml unchanged"
exit 0
fi
git commit -m "[skip ci] Update locales_config.xml."
git push origin HEAD:main
- name: Commit libsoulamz prebuilts
if: needs.decide-build.outputs.on_main == 'true'
run: |
set -euo pipefail
chmod +x scripts/soulamz-stamp.sh
NEW_HASH="$(./scripts/soulamz-stamp.sh hash)"
OLD_HASH="$(tr -d '[:space:]' < bundled-warp/soulamz.inputs 2>/dev/null || true)"
if [[ "${NEW_HASH}" == "${OLD_HASH}" ]]; then
echo "soulamz inputs unchanged (${NEW_HASH})"
exit 0
fi
if [[ ! -f bundled-warp/lib/arm64-v8a/libsoulamz.so ]]; then
echo "error: soulamz prebuilts missing after build (Go/NDK required)" >&2
exit 1
fi
libsoulamz_stash="/tmp/bundled-warp-lib"
rm -rf "${libsoulamz_stash}"
cp -a bundled-warp/lib "${libsoulamz_stash}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main
git reset --hard origin/main
rm -rf bundled-warp/lib
cp -a "${libsoulamz_stash}" bundled-warp/lib
printf '%s\n' "${NEW_HASH}" > bundled-warp/soulamz.inputs
git add bundled-warp/soulamz.inputs
git add -f bundled-warp/lib/
git commit -m "[skip ci] Update libsoulamz prebuilts."
git push origin HEAD:main
- name: Commit OCR/WARP tooling prebuilts
if: needs.decide-build.outputs.on_main == 'true'
run: |
set -euo pipefail
chmod +x scripts/tooling-stamp.sh
NEW_HASH="$(./scripts/tooling-stamp.sh hash)"
OLD_HASH="$(tr -d '[:space:]' < prebuilts/tooling.inputs 2>/dev/null || true)"
if [[ "${NEW_HASH}" == "${OLD_HASH}" ]]; then
echo "tooling inputs unchanged (${NEW_HASH})"
exit 0
fi
for f in prebuilts/classes_ocr.dat prebuilts/classes_ocr_installer.dat prebuilts/classes_warp.dat; do
if [[ ! -f "${f}" ]]; then
echo "error: tooling prebuilt missing after build: ${f}" >&2
exit 1
fi
done
tooling_stash="/tmp/tooling-prebuilts"
rm -rf "${tooling_stash}"
cp -a prebuilts "${tooling_stash}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main
git reset --hard origin/main
rm -rf prebuilts
cp -a "${tooling_stash}" prebuilts
git add prebuilts/
git commit -m "[skip ci] Update OCR/WARP tooling prebuilts."
git push origin HEAD:main
- name: Remove signing keystore
if: always()
run: rm -f keystore/release.keystore
- name: Resolve signed APK
id: apk
run: |
shopt -s nullglob
candidates=()
for f in dist/soul-browser-*.apk; do
case "$f" in
*-unsigned.apk|*-aligned.apk) ;;
*) candidates+=("$f") ;;
esac
done
if [[ ${#candidates[@]} -ne 1 ]]; then
echo "Expected exactly one signed APK, found: ${candidates[*]:-none}" >&2
ls -la dist/ || true
exit 1
fi
echo "path=${candidates[0]}" >> "$GITHUB_OUTPUT"
- name: Upload APK artifact
id: upload_artifact
continue-on-error: true
uses: actions/upload-artifact@v7
with:
path: ${{ steps.apk.outputs.path }}
archive: false
if-no-files-found: error
- name: Upload APK to R2 (artifact quota fallback)
if: steps.upload_artifact.outcome == 'failure'
env:
# Access Key ID is a variable (not a secret): it is embedded in
# presigned URLs, and GitHub would mask a secret value to "***".
AWS_ACCESS_KEY_ID: ${{ vars.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
R2_ENDPOINT: https://673b6d22cf5cd200862a9c97dbe22ec6.r2.cloudflarestorage.com
R2_BUCKET: github-actions-artifacts
APK_PATH: ${{ steps.apk.outputs.path }}
run: |
set -euo pipefail
if [[ -z "${AWS_ACCESS_KEY_ID:-}" || -z "${AWS_SECRET_ACCESS_KEY:-}" ]]; then
echo "R2_ACCESS_KEY_ID (variable) / R2_SECRET_ACCESS_KEY (secret) are not configured." >&2
exit 1
fi
filename="$(basename "$APK_PATH")"
key="soul-browser/${{ github.run_id }}/${{ github.run_attempt }}/${filename}"
object_url="${R2_ENDPOINT}/${R2_BUCKET}/${key}"
echo "GitHub artifact upload failed; uploading to Cloudflare R2 as fallback..."
aws s3 cp "$APK_PATH" "s3://${R2_BUCKET}/${key}" --endpoint-url "$R2_ENDPOINT"
# Bucket is private; presign so the step summary link is downloadable.
download_url="$(aws s3 presign "s3://${R2_BUCKET}/${key}" --endpoint-url "$R2_ENDPOINT" --expires-in 604800)"
{
echo "## APK (R2 fallback)"
echo
echo "GitHub Actions artifact upload failed (often: Artifact storage quota has been hit)."
echo
echo "- Object: \`${object_url}\`"
echo "- Download (7-day signed URL): [${filename}](${download_url})"
} >> "$GITHUB_STEP_SUMMARY"
echo "Uploaded to ${object_url}"
# Also print to logs so agents can fetch without opening the Actions UI.
echo "R2_DOWNLOAD_URL=${download_url}"
- name: Analyze APK with Exodus Privacy
# Only when decide-build marked this tag as on main.
if: needs.decide-build.outputs.on_main == 'true'
env:
APK_PATH: ${{ steps.apk.outputs.path }}
run: |
set -euo pipefail
apk_name="$(basename "$APK_PATH")"
report_file="exodus_privacy_report_v2_latest.txt"
max_attempts=5
for attempt in $(seq 1 "${max_attempts}"); do
rm -f "${report_file}"
if docker run --rm \
-v "${GITHUB_WORKSPACE}/dist:/app:ro" \
exodusprivacy/exodus-standalone \
"/app/${apk_name}" \
> "${report_file}"; then
break
fi
if [[ "${attempt}" -eq "${max_attempts}" ]]; then
echo "Exodus Privacy analysis failed after ${max_attempts} attempts." >&2
exit 1
fi
delay=$((attempt * 30))
echo "Exodus attempt ${attempt} failed (often transient DNS to reports.exodus-privacy.eu.org); retrying in ${delay}s..."
sleep "${delay}"
done
if [[ ! -s "${report_file}" ]]; then
echo "Exodus Privacy report is empty." >&2
exit 1
fi
python3 - <<'PY'
from pathlib import Path
path = Path("exodus_privacy_report_v2_latest.txt")
raw = path.read_text()
lines = raw.splitlines()
out = []
i = 0
while i < len(lines):
line = lines[i]
if line.startswith("- App permissions:"):
out.append(line)
i += 1
perms = []
while i < len(lines) and lines[i].startswith(" - "):
perms.append(lines[i])
i += 1
out.extend(sorted(perms))
continue
out.append(line)
i += 1
ending = "\n" if raw.endswith("\n") else ""
path.write_text("\n".join(out) + ending)
PY
cat exodus_privacy_report_v2_latest.txt
- name: Commit Exodus Privacy report
if: needs.decide-build.outputs.on_main == 'true'
run: |
set -euo pipefail
report_file="exodus_privacy_report_v2_latest.txt"
cp "$report_file" /tmp/exodus_privacy_report_v2_latest.txt
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Tag workflows commit the report back to main (not to the tag ref).
git fetch origin main
git reset --hard origin/main
cp /tmp/exodus_privacy_report_v2_latest.txt "$report_file"
git add "$report_file"
git commit -m "[skip ci] Update Exodus Privacy report."
git push origin HEAD:main