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>
497 lines
19 KiB
YAML
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
|