diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4b569602c3..91497b3535 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -14,6 +14,11 @@ name: Nightly on: + push: + branches: [master] # build a nightly on every push to master + paths-ignore: + - '**/*.md' # skip doc-only pushes + - 'docs/**' schedule: - cron: '0 8 * * *' # 08:00 UTC daily workflow_dispatch: @@ -35,6 +40,7 @@ jobs: proceed: ${{ steps.check.outputs.proceed }} date: ${{ steps.check.outputs.date }} sha: ${{ steps.check.outputs.sha }} + notes: ${{ steps.notes.outputs.body }} steps: - uses: actions/checkout@v7 with: @@ -57,6 +63,58 @@ jobs: echo "proceed=$PROCEED" >> "$GITHUB_OUTPUT" echo "proceed=$PROCEED date=$DATE sha=$SHA" + # Plain-language changelog for the release body: one bullet per commit + # since the previous nightly tag, with noise filtered and dev "[scope]" + # tags stripped. No hashes/emojis/dashes. Built here because gate already + # has full history (+ tags) checked out; the publish job (which has no + # checkout) consumes it via needs.gate.outputs.notes. + - id: notes + run: | + DATE="${{ steps.check.outputs.date }}" + REPO="${{ github.repository }}" + # most recent nightly-* tag that isn't today's (empty on the first nightly) + PREV="$(git tag -l 'nightly-*' --sort=-creatordate | grep -vx "nightly-${DATE}" | head -1 || true)" + if [ -n "$PREV" ] && git rev-parse -q --verify "refs/tags/${PREV}" >/dev/null; then + RANGE="${PREV}..HEAD" + elif git rev-parse -q --verify "HEAD~20" >/dev/null; then + RANGE="HEAD~20..HEAD" # first nightly: fall back to the last 20 commits + else + RANGE="HEAD" + fi + # Turn commit subjects into plain user-facing bullets: + # - drop internal commits (merge/revert/ci/chore/docs/build/style/test/ + # refactor, translation syncs, version/cmake bumps) + # - strip conventional "feat/fix/perf:" prefixes and leading "[scope]" tags + # - convert em/en dashes (—/–) to commas so no AI-style dashes survive + # || true keeps head's SIGPIPE from tripping pipefail; empty output is fine. + git log "$RANGE" --no-merges --pretty=format:'%s' \ + | grep -ivE '^(merge |revert )' \ + | grep -ivE '^(ci|chore|docs|build|style|test|refactor)(\(|:)' \ + | grep -ivE 'update translations|transifex|bump version|^version |^cmake ' \ + | sed -E 's/^(feat|fix|perf)(\([^)]*\))?!?: *//' \ + | sed 's/ *— */, /g; s/ *– */, /g' \ + | sed 's/^\[[^]]*\] *//' \ + | sed 's/^/- /' \ + | head -100 > changes.txt || true + { + echo "Automated nightly build for community testing (unsigned)." + echo + echo "Platforms: macOS arm64, Linux arm64 (4K and 16K pages), Windows arm64, Android (sideloadable APK). iOS is not included yet." + echo + echo "## What's new" + if [ -s changes.txt ]; then cat changes.txt; else echo "- No notable changes since the last nightly."; fi + if [ -n "$PREV" ]; then + echo + echo "Full changelog: https://github.com/${REPO}/compare/${PREV}...${{ github.sha }}" + fi + } > notes.md + { + echo 'body<> "$GITHUB_OUTPUT" + echo "----- generated release notes -----"; cat notes.md + # ---- PC arm64 Qt GUI (blocking for the release) ------------------------ pc-macos-arm64: name: PC · macOS arm64 (Qt) @@ -131,16 +189,38 @@ jobs: - name: Fetch shaderc third-party deps run: python3 app/src/main/cpp/3rdparty/shaderc/utils/git-sync-deps - # Release build; when armsx2_keystore.properties is absent (it is, in CI) the - # release APK is debug-key-signed -> installable/sideloadable, not Play-grade. - - name: Build release APK - run: ./gradlew :app:assembleRelease --stacktrace + # Dual-core (4k + 16k page size) + PGO nightly. A stock `assembleRelease` + # ships ONLY the 4k core and cannot load its native core on 16k-page + # devices (Android 15+ handhelds). This compiles the core at both page + # sizes and merges both .so's into one APK, with PGO=optimize so the + # nightly matches release performance. See tools/ci-nightly-dualcore.sh. + - name: Build dual-core (4k + 16k) + PGO APK + env: + FLAVOR: Github # sideload build (com.armsx2); use Play for the SAF-only build + # Optional stable signing: add repo secrets NIGHTLY_KEYSTORE_B64 (base64 of a + # .jks) + NIGHTLY_KS_PASS / NIGHTLY_KEY_ALIAS / NIGHTLY_KEY_PASS so every + # nightly is signed with one key and installs as an update. Without them a + # throwaway debug key is generated (users reinstall between nightlies). + NIGHTLY_KS_PASS: ${{ secrets.NIGHTLY_KS_PASS }} + NIGHTLY_KEY_ALIAS: ${{ secrets.NIGHTLY_KEY_ALIAS }} + NIGHTLY_KEY_PASS: ${{ secrets.NIGHTLY_KEY_PASS }} + run: | + export VC="${{ github.run_number }}" + export VN="$(grep 'versionName' app/build.gradle.kts | sed -n 's/.*?:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)" + export VN="${VN:-nightly}" + if [ -n "${{ secrets.NIGHTLY_KEYSTORE_B64 }}" ]; then + echo "${{ secrets.NIGHTLY_KEYSTORE_B64 }}" | base64 -d > "$RUNNER_TEMP/nightly.jks" + export NIGHTLY_KEYSTORE="$RUNNER_TEMP/nightly.jks" + fi + bash tools/ci-nightly-dualcore.sh - name: Upload APK uses: actions/upload-artifact@v7 with: name: armsx2-android-arm64 - path: platforms/android/app/build/outputs/apk/**/*.apk + # ONLY the merged dual-core APK — not the intermediate single-core bases — + # so the publish job attaches exactly one APK to the release. + path: platforms/android/app/build/outputs/apk/nightly/*.apk if-no-files-found: warn # ---- publish: gather artifacts -> dated pre-release -------------------- @@ -185,11 +265,9 @@ jobs: prerelease: true target_commitish: ${{ github.sha }} fail_on_unmatched_files: false - body: | - Automated **nightly** build from `${{ github.sha }}` — unsigned, for community testing. - - **Platforms:** macOS arm64 · Linux arm64 (4K + 16K pages) · Windows arm64 · Android (sideloadable APK). - _iOS is not yet included — the device build is still being brought up._ + # Eden-style release notes (static intro + per-commit changelog + Full + # Changelog link) assembled by the gate job, which has git history. + body: ${{ needs.gate.outputs.notes }} files: out/* - name: Discord announce diff --git a/platforms/android/.gitignore b/platforms/android/.gitignore index 3b1b995307..4c3b13559c 100644 --- a/platforms/android/.gitignore +++ b/platforms/android/.gitignore @@ -55,3 +55,6 @@ app/src/main/cpp/3rdparty/shaderc/third_party/googletest/ app/src/main/cpp/3rdparty/shaderc/third_party/re2/ app/src/main/cpp/3rdparty/shaderc/third_party/spirv-headers/ app/src/main/cpp/3rdparty/shaderc/third_party/spirv-tools/ + +# CI PGO profile for the dual-core nightly (build-release recipe uses this) +!pgo/armsx2.profdata diff --git a/platforms/android/pgo/armsx2.profdata b/platforms/android/pgo/armsx2.profdata new file mode 100644 index 0000000000..b44dbdb0b6 Binary files /dev/null and b/platforms/android/pgo/armsx2.profdata differ diff --git a/platforms/android/tools/ci-nightly-dualcore.sh b/platforms/android/tools/ci-nightly-dualcore.sh new file mode 100755 index 0000000000..84c611fd3f --- /dev/null +++ b/platforms/android/tools/ci-nightly-dualcore.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# ============================================================================ +# ci-nightly-dualcore.sh — GitHub Actions dual-core (4k + 16k) + PGO nightly. +# ---------------------------------------------------------------------------- +# A stock `./gradlew :app:assembleRelease` produces ONE core at the default +# host page size (emucore_4k @ 0x1000). That APK cannot load its native core on +# 16k-page devices (Android 15+ handhelds), so half the fleet is broken. +# +# PCSX2 fastmem bakes the host page size in at compile time, so a universal APK +# needs the core compiled TWICE (0x1000 and 0x4000) and both .so's merged into +# one APK — exactly what tools/build-release-apk.sh does for hand-built +# releases. This is the CI-portable (Linux, no macOS assumptions) version of +# that recipe, plus PGO=optimize so the nightly matches release performance. +# +# Runs from platforms/android (the gradle root); the nightly workflow sets +# working-directory accordingly. +# +# Env: +# VC, VN versionCode / versionName (required) +# FLAVOR Github (sideload, com.armsx2) | Play (default Github) +# PROF merged .profdata (default pgo/armsx2.profdata; if the +# file is absent the build falls back to PGO=none rather +# than failing, so a missing profile never breaks CI) +# OUT output apk path +# NIGHTLY_KEYSTORE / NIGHTLY_KS_PASS / NIGHTLY_KEY_ALIAS / NIGHTLY_KEY_PASS +# stable signing key (recommended: add as a repo secret +# so nightly-over-nightly updates install; otherwise a +# throwaway debug key is generated and users must +# reinstall between nightlies). +# ============================================================================ +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # platforms/android +GRADLE="$ROOT_DIR/gradlew" + +VC="${VC:?set VC=}" +VN="${VN:?set VN=}" +FLAVOR="${FLAVOR:-Github}" # Github | Play +flavor_lc="$(printf '%s' "$FLAVOR" | tr '[:upper:]' '[:lower:]')" +PROF="${PROF:-$ROOT_DIR/pgo/armsx2.profdata}" +OUT="${OUT:-$ROOT_DIR/app/build/outputs/apk/nightly/ARMSX2-nightly-${VN}-vc${VC}.apk}" + +# --- resolve Android SDK build-tools (Linux CI exports ANDROID_HOME/SDK_ROOT) --- +SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +[[ -n "$SDK" && -d "$SDK" ]] || { echo "FATAL: ANDROID_HOME/ANDROID_SDK_ROOT not set" >&2; exit 1; } +BT="$(find "$SDK/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)" +ZIPALIGN="$BT/zipalign"; APKSIGNER="$BT/apksigner"; AAPT="$BT/aapt2" +for t in "$ZIPALIGN" "$APKSIGNER"; do + [[ -x "$t" ]] || { echo "FATAL: missing build-tool $t" >&2; exit 1; } +done + +# --- PGO: optimize when the profile exists, else fall back so CI never fails --- +if [[ -f "$PROF" ]]; then + PGO_ARGS=(-Parmsx2.pgo=optimize -Parmsx2.pgoProfile="$PROF") + echo "PGO: optimize using $PROF ($(( $(wc -c < "$PROF") / 1024 / 1024 )) MB)" +else + PGO_ARGS=(-Parmsx2.pgo=none) + echo "WARNING: PGO profile not found at $PROF — building PGO=none (slower core)." >&2 +fi + +WORK="$ROOT_DIR/app/build/nightly-dualcore"; rm -rf "$WORK" +mkdir -p "$WORK/lib-stage/lib/arm64-v8a" "$(dirname "$OUT")" +BUILT="$ROOT_DIR/app/build/outputs/apk/${flavor_lc}/release/app-${flavor_lc}-release.apk" + +fsize() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1"; } # Linux || macOS + +build_core() { # pagesize libname + local ps="$1" ln="$2" + echo "=== core $ln (page=$ps, flavor=$FLAVOR) ===" + rm -f "$BUILT" + "$GRADLE" -p "$ROOT_DIR" ":app:assemble${FLAVOR}Release" \ + -Parmsx2.hostPageSize="$ps" \ + -Parmsx2.nativeLibName="$ln" \ + "${PGO_ARGS[@]}" \ + -Parmsx2.versionCode="$VC" \ + -Parmsx2.versionName="$VN" \ + --stacktrace + [[ -f "$BUILT" ]] || { echo "FATAL: gradle produced no APK for $ln ($BUILT)" >&2; exit 1; } + unzip -l "$BUILT" "lib/arm64-v8a/lib${ln}.so" >/dev/null \ + || { echo "FATAL: lib${ln}.so missing from $BUILT" >&2; exit 1; } + cp -f "$BUILT" "$WORK/base-${ln}.apk" + unzip -p "$BUILT" "lib/arm64-v8a/lib${ln}.so" > "$WORK/lib-stage/lib/arm64-v8a/lib${ln}.so" +} + +build_core 0x1000 emucore_4k +build_core 0x4000 emucore_16k + +for so in emucore_4k emucore_16k; do + sz="$(fsize "$WORK/lib-stage/lib/arm64-v8a/lib${so}.so")" + echo " lib${so}.so = $(( sz / 1024 / 1024 )) MB" + [[ "$sz" -gt 10000000 ]] || { echo "FATAL: lib${so}.so too small ($sz bytes)" >&2; exit 1; } +done + +# --- merge: 4k APK as base, drop old signatures + both cores, re-add STORED --- +UNS="$WORK/universal-unsigned.apk"; ALN="$WORK/universal-aligned.apk" +cp -f "$WORK/base-emucore_4k.apk" "$UNS" +zip -qd "$UNS" "META-INF/*" >/dev/null 2>&1 || true +zip -qd "$UNS" "lib/arm64-v8a/libemucore_4k.so" "lib/arm64-v8a/libemucore_16k.so" >/dev/null 2>&1 || true +( cd "$WORK/lib-stage" && zip -qr -0 "$UNS" lib ) +"$ZIPALIGN" -f -P 16 4 "$UNS" "$ALN" + +# --- sign: stable key if provided, else a throwaway debug key (warn) ---------- +KS="${NIGHTLY_KEYSTORE:-$HOME/.android/debug.keystore}" +KS_PASS="${NIGHTLY_KS_PASS:-android}" +KS_ALIAS="${NIGHTLY_KEY_ALIAS:-androiddebugkey}" +KEY_PASS="${NIGHTLY_KEY_PASS:-android}" +if [[ ! -f "$KS" ]]; then + echo "WARNING: no keystore at $KS — generating a throwaway debug key." >&2 + echo " Set NIGHTLY_KEYSTORE (repo secret) for stable nightly-over-nightly updates." >&2 + mkdir -p "$(dirname "$KS")" + keytool -genkeypair -keystore "$KS" -storepass "$KS_PASS" -keypass "$KEY_PASS" \ + -alias "$KS_ALIAS" -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" >/dev/null 2>&1 +fi +rm -f "$OUT" +"$APKSIGNER" sign --ks "$KS" --ks-key-alias "$KS_ALIAS" \ + --ks-pass "pass:$KS_PASS" --key-pass "pass:$KEY_PASS" \ + --out "$OUT" "$ALN" + +echo; echo "================= VERIFY =================" +echo "-- both cores present --" +unzip -l "$OUT" | grep -E "libemucore_(4k|16k)\.so" || { echo "FATAL: cores missing" >&2; exit 1; } +echo "-- 16k alignment --"; "$ZIPALIGN" -c -P 16 4 "$OUT" && echo " align OK" +echo "-- signature --"; "$APKSIGNER" verify "$OUT" && echo " sig OK" +[[ -x "$AAPT" ]] && "$AAPT" dump badging "$OUT" 2>/dev/null | grep -E "package: name|versionCode|versionName" | head -2 +echo; echo "OUTPUT: $OUT" +echo "NIGHTLY-DUALCORE-DONE"