diff --git a/.github/workflows/ci-channel.yml b/.github/workflows/ci-channel.yml
index 380634226..ec40a22c5 100644
--- a/.github/workflows/ci-channel.yml
+++ b/.github/workflows/ci-channel.yml
@@ -51,6 +51,16 @@ jobs:
test -f kernel.properties
! rg -q 'channel: smart|kernel_channel: smart|YumeBox-smart' .github/workflows/ci-channel.yml
+ # Prebuild Java/Kotlin & module resources in parallel with the native build jobs.
+ prebuild-modules-pre:
+ name: "01 · Prebuild Modules (Pre)"
+ needs: validate-channel
+ uses: ./.github/workflows/reusable-prebuild-modules.yml
+ with:
+ channel: pre
+ build_type: release
+ secrets: inherit
+
# Keep the channel and ABI as matrix inputs even while each has one supported value.
# Adding a supported target later expands the graph without changing artifact wiring.
build-geo-pre:
@@ -100,7 +110,9 @@ jobs:
build-apk-pre-app:
name: "05 · APK (Pre)"
- needs: bundle-native-pre
+ needs:
+ - bundle-native-pre
+ - prebuild-modules-pre
uses: ./.github/workflows/reusable-build-apk-only.yml
with:
channel: pre
@@ -108,6 +120,7 @@ jobs:
release_tag: Pre-release
meta_asset_name: YumeBox-pre-meta.json
raw_apk_artifact_name: apk-raw-pre-app
+ build_type: release
build_hash: ${{ github.sha }}
build_branch: ${{ github.ref_name }}
secrets: inherit
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index f59007c36..35a257826 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -16,6 +16,15 @@ permissions:
contents: read
jobs:
+ # Prebuild Java/Kotlin & module resources in parallel with the native build jobs.
+ prebuild-modules-pr:
+ name: Prebuild Modules (PR)
+ uses: ./.github/workflows/reusable-prebuild-modules.yml
+ with:
+ channel: pr
+ build_type: debug
+ secrets: inherit
+
build-geo-pr:
name: Geo (PR)
uses: ./.github/workflows/reusable-build-geo.yml
@@ -49,7 +58,9 @@ jobs:
# number in the release tag) additionally identifies the build.
build-apk-pr-app:
name: APK (PR)
- needs: bundle-native-pr
+ needs:
+ - bundle-native-pr
+ - prebuild-modules-pr
uses: ./.github/workflows/reusable-build-apk-only.yml
with:
channel: pr
@@ -57,6 +68,7 @@ jobs:
release_tag: pr-${{ github.event.pull_request.number }}
meta_asset_name: YumeBox-pr-meta.json
raw_apk_artifact_name: apk-raw-pr-app
+ build_type: debug
build_hash: ${{ github.event.pull_request.head.sha }}
build_branch: ${{ github.event.pull_request.head.ref }}
secrets: inherit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cd02166b0..bc1f09aef 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -32,6 +32,15 @@ permissions:
# publish-release runs with prerelease=false -> generate_release_notes=false (empty body,
# edited by hand afterwards) and make_latest=true (channel == 'stable').
jobs:
+ # Prebuild Java/Kotlin & module resources in parallel with the native build jobs.
+ prebuild-modules-stable:
+ name: Prebuild Modules (Stable)
+ uses: ./.github/workflows/reusable-prebuild-modules.yml
+ with:
+ channel: stable
+ build_type: release
+ secrets: inherit
+
build-geo-stable:
name: Geo (Stable)
uses: ./.github/workflows/reusable-build-geo.yml
@@ -60,7 +69,9 @@ jobs:
build-apk-stable-app:
name: APK (Stable)
- needs: bundle-native-stable
+ needs:
+ - bundle-native-stable
+ - prebuild-modules-stable
uses: ./.github/workflows/reusable-build-apk-only.yml
with:
channel: stable
@@ -68,6 +79,7 @@ jobs:
release_tag: ${{ inputs.release_tag }}
meta_asset_name: YumeBox-stable-meta.json
raw_apk_artifact_name: apk-raw-stable-app
+ build_type: release
secrets: inherit
prepare-publish-stable-app:
diff --git a/.github/workflows/reusable-build-apk-only.yml b/.github/workflows/reusable-build-apk-only.yml
index 13556728a..3f739569c 100644
--- a/.github/workflows/reusable-build-apk-only.yml
+++ b/.github/workflows/reusable-build-apk-only.yml
@@ -18,6 +18,11 @@ on:
raw_apk_artifact_name:
required: true
type: string
+ build_type:
+ description: "Gradle build type (release or debug); defaults to release."
+ required: false
+ type: string
+ default: "release"
build_hash:
description: "Commit sha of the build; first 8 chars go into versionName ([.].)."
required: false
@@ -31,37 +36,71 @@ on:
outputs:
apk_name_tail:
description: "Tail used in Gradle's published APK file names; channel and PR builds use one Asia/Shanghai packaging date, stable builds use the clean version name."
- value: ${{ jobs.build.outputs.apk_name_tail }}
+ value: ${{ jobs.prepare-build.outputs.apk_name_tail }}
permissions:
contents: read
jobs:
- build:
+ prepare-build:
+ name: Prepare Build Meta
runs-on: ubuntu-26.04
outputs:
+ build_number: ${{ steps.build_number.outputs.value }}
apk_name_tail: ${{ steps.apk_name.outputs.tail }}
- env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ channel_segment: ${{ steps.apk_name.outputs.channel_segment }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
- submodules: recursive
fetch-depth: 0
- # Build number N = commit count of the built commit's parent chain. Computed here
- # instead of taken from the caller so ci-channel, pull-request and release all share
- # one versionCode formula: epoch (project.version.code in gradle.properties) + N.
- # Requires the fetch-depth:0 checkout above - a shallow clone would count 1.
- # Monotonic vs the retired ci-channel run_number scheme (commit count >= pushes >=
- # run_number) and immune to workflow-file renames. reusable-prepare-publish.yml
- # recomputes the same N from the same github.sha.
- name: Compute build number
id: build_number
shell: bash
run: echo "value=$(git rev-list --count HEAD)" >> "$GITHUB_OUTPUT"
+ - name: Compute APK output name
+ id: apk_name
+ shell: bash
+ env:
+ BUILD_HASH: ${{ inputs.build_hash }}
+ run: |
+ set -eu
+ if [ -n "${BUILD_HASH}" ]; then
+ tail="$(TZ=Asia/Shanghai date +%y.%m.%d)"
+ else
+ tail="$(grep '^project.version.name=' gradle.properties | cut -d= -f2-)"
+ fi
+ case "${{ inputs.channel }}" in
+ pr) channel_segment="${{ inputs.channel }}" ;;
+ pre|stable) channel_segment="" ;;
+ *) echo "Unsupported APK output channel: ${{ inputs.channel }}" >&2; exit 1 ;;
+ esac
+ echo "tail=${tail}" >> "$GITHUB_OUTPUT"
+ echo "channel_segment=${channel_segment}" >> "$GITHUB_OUTPUT"
+
+ build-variant:
+ name: APK (${{ matrix.variant }})
+ needs: prepare-build
+ runs-on: ubuntu-26.04
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - variant: builtin
+ geo_bundle: "true"
+ - variant: external
+ geo_bundle: "false"
+ env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ submodules: recursive
+ fetch-depth: 0
+
- name: Setup Java
uses: actions/setup-java@v5
with:
@@ -74,6 +113,13 @@ jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5
+ - name: Download prebuilt gradle build-cache
+ uses: actions/download-artifact@v8
+ continue-on-error: true
+ with:
+ name: gradle-build-cache-${{ inputs.channel }}
+ path: /home/runner/.gradle/caches/build-cache-1
+
- name: Setup Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
with:
@@ -116,12 +162,15 @@ jobs:
done
# Core branch/hash stamp from the native job (About / BuildConfig.CORE_*).
test -f "jniLibs/arm64-v8a/core-version.properties" || test -f "build/generated/core-version.properties"
- test -f "build/generated/assets/geo/geoip.metadb.xz"
- test -f "build/generated/assets/geo/geosite.dat.xz"
- test -f "build/generated/assets/geo/ASN.mmdb.xz"
- test ! -e "build/generated/assets/geo/BundleMRS.7z"
+ if [ "${{ matrix.geo_bundle }}" = "true" ]; then
+ test -f "build/generated/assets/geo/geoip.metadb.xz"
+ test -f "build/generated/assets/geo/geosite.dat.xz"
+ test -f "build/generated/assets/geo/ASN.mmdb.xz"
+ test ! -e "build/generated/assets/geo/BundleMRS.7z"
+ fi
- name: Restore signing keystore
+ if: inputs.build_type == 'release'
env:
SIGNING_KEYSTORE_BASE64: ${{ secrets.SIGNING_KEYSTORE_BASE64 }}
run: |
@@ -130,6 +179,7 @@ jobs:
fi
- name: Write signing config
+ if: inputs.build_type == 'release'
run: |
if [ -n "${{ secrets.SIGNING_STORE_PASSWORD }}" ]; then
echo "keystore.password=${{ secrets.SIGNING_STORE_PASSWORD }}" > signing.properties
@@ -138,35 +188,18 @@ jobs:
echo "keystore.path=release.keystore" >> signing.properties
fi
- - name: Compute APK output name
- id: apk_name
- shell: bash
- env:
- BUILD_HASH: ${{ inputs.build_hash }}
- run: |
- set -eu
- if [ -n "${BUILD_HASH}" ]; then
- tail="$(TZ=Asia/Shanghai date +%y.%m.%d)"
- else
- tail="$(grep '^project.version.name=' gradle.properties | cut -d= -f2-)"
- fi
- case "${{ inputs.channel }}" in
- pr) channel_segment="${{ inputs.channel }}" ;;
- pre|stable) channel_segment="" ;;
- *) echo "Unsupported APK output channel: ${{ inputs.channel }}" >&2; exit 1 ;;
- esac
- echo "tail=${tail}" >> "$GITHUB_OUTPUT"
- echo "channel_segment=${channel_segment}" >> "$GITHUB_OUTPUT"
-
- - name: Build Release APKs (geo + nogeo)
+ - name: Assemble APK
shell: bash
env:
- BUILD_NUMBER: ${{ steps.build_number.outputs.value }}
+ BUILD_NUMBER: ${{ needs.prepare-build.outputs.build_number }}
BUILD_HASH: ${{ inputs.build_hash }}
BUILD_BRANCH: ${{ inputs.build_branch }}
run: |
set -eu
chmod +x gradlew
+ build_type="${{ inputs.build_type }}"
+ cap_type="$(tr '[:lower:]' '[:upper:]' <<< "${build_type:0:1}")${build_type:1}"
+
common_args=(
-Pupdate.channel="${{ inputs.channel }}"
-Pupdate.tag="${{ inputs.release_tag }}"
@@ -174,40 +207,56 @@ jobs:
-Pbuild.number="${BUILD_NUMBER}"
-Pbuild.hash="${BUILD_HASH:0:8}"
-Pbuild.branch="${BUILD_BRANCH}"
- -Papk.output.tail="${{ steps.apk_name.outputs.tail }}"
- -Papk.output.channel="${{ steps.apk_name.outputs.channel_segment }}"
+ -Papk.output.tail="${{ needs.prepare-build.outputs.apk_name_tail }}"
+ -Papk.output.channel="${{ needs.prepare-build.outputs.channel_segment }}"
+ -Pgeo.bundle="${{ matrix.geo_bundle }}"
)
- apk_output_dir="${{ inputs.module }}/build/outputs/apk/release"
+ apk_output_dir="${{ inputs.module }}/build/outputs/apk/${build_type}"
shopt -s nullglob
mkdir -p apk-stage
- # Do not let a rerun or a second packaging pass reuse an APK left in either
- # staging directory. Each pass must contribute exactly one named variant.
rm -f apk-stage/*.apk "${apk_output_dir}"/*.apk
- ./gradlew --no-daemon "${common_args[@]}" -Pgeo.bundle=true :${{ inputs.module }}:assembleRelease
- builtin_apks=("${apk_output_dir}"/*-builtin-*.apk)
- test "${#builtin_apks[@]}" -eq 1
- cp "${builtin_apks[0]}" apk-stage/
+ ./gradlew --no-daemon --build-cache "${common_args[@]}" :${{ inputs.module }}:assemble${cap_type}
+
+ variant_name="${{ matrix.variant }}"
+ built_apks=("${apk_output_dir}"/*-${variant_name}-*.apk)
+ test "${#built_apks[@]}" -eq 1
+ cp "${built_apks[0]}" apk-stage/
+
+ - name: Upload variant APK artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: ${{ inputs.raw_apk_artifact_name }}-${{ matrix.variant }}
+ path: apk-stage/*.apk
+ if-no-files-found: error
+ compression-level: 0
- # AGP may leave the previous variant in its output directory when only a
- # packaging property changes. Remove it before the external pass so a broad
- # glob can never copy the builtin APK a second time.
- rm -f "${apk_output_dir}"/*.apk
+ aggregate-raw-apks:
+ name: Aggregate Raw APKs
+ needs:
+ - prepare-build
+ - build-variant
+ runs-on: ubuntu-26.04
+ steps:
+ - name: Download builtin APK
+ uses: actions/download-artifact@v8
+ with:
+ name: ${{ inputs.raw_apk_artifact_name }}-builtin
+ path: apk-stage
- # Second pass only re-merges assets and re-packages; compile and R8 stay
- # up to date because geo.bundle feeds no compiler input (no BuildConfig field).
- ./gradlew --no-daemon "${common_args[@]}" -Pgeo.bundle=false :${{ inputs.module }}:assembleRelease
- external_apks=("${apk_output_dir}"/*-external-*.apk)
- test "${#external_apks[@]}" -eq 1
- cp "${external_apks[0]}" apk-stage/
+ - name: Download external APK
+ uses: actions/download-artifact@v8
+ with:
+ name: ${{ inputs.raw_apk_artifact_name }}-external
+ path: apk-stage
- name: Verify staged APK matrix
shell: bash
run: |
set -eu
- tail="${{ steps.apk_name.outputs.tail }}"
- channel_segment="${{ steps.apk_name.outputs.channel_segment }}"
+ tail="${{ needs.prepare-build.outputs.apk_name_tail }}"
+ channel_segment="${{ needs.prepare-build.outputs.channel_segment }}"
channel_suffix="${channel_segment:+-${channel_segment}}"
ls "apk-stage/YumeBox-external${channel_suffix}-${tail}.apk"
ls "apk-stage/YumeBox-builtin${channel_suffix}-${tail}.apk"
@@ -223,7 +272,7 @@ jobs:
- First-time users should choose `builtin` with bundled Geo data; both variants provide the same application features and core code.
EOF
- - name: Upload raw APK artifact
+ - name: Upload aggregated raw APK artifact
uses: actions/upload-artifact@v7
with:
name: ${{ inputs.raw_apk_artifact_name }}
diff --git a/.github/workflows/reusable-cleanup-artifacts.yml b/.github/workflows/reusable-cleanup-artifacts.yml
index 09f6b608a..0fb0f3ca2 100644
--- a/.github/workflows/reusable-cleanup-artifacts.yml
+++ b/.github/workflows/reusable-cleanup-artifacts.yml
@@ -1,7 +1,7 @@
name: Cleanup Artifacts
# Deletes this run's intermediate artifacts (native libs, geo payloads, raw
-# APK stages) once the pipeline has succeeded, keeping only the user-facing
+# APK stages, prebuild gradle caches) once the pipeline has succeeded, keeping only the user-facing
# APK artifacts (publish-* aggregates and per-ABI installers). Callers wire
# it behind their terminal jobs, so a failed run keeps everything for
# debugging and job re-runs.
@@ -22,7 +22,7 @@ jobs:
run: |
set -eu
ids=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" --paginate \
- --jq '.artifacts[] | select(.name | startswith("native-") or startswith("apk-raw-")) | .id')
+ --jq '.artifacts[] | select(.name | startswith("native-") or startswith("apk-raw-") or startswith("gradle-build-cache-")) | .id')
if [ -z "${ids}" ]; then
echo "No intermediate artifacts to delete."
exit 0
diff --git a/.github/workflows/reusable-prebuild-modules.yml b/.github/workflows/reusable-prebuild-modules.yml
new file mode 100644
index 000000000..d66d44e72
--- /dev/null
+++ b/.github/workflows/reusable-prebuild-modules.yml
@@ -0,0 +1,87 @@
+name: Prebuild Modules
+
+on:
+ workflow_call:
+ inputs:
+ channel:
+ required: true
+ type: string
+ build_type:
+ description: "Gradle build type (release or debug); defaults to release."
+ required: false
+ type: string
+ default: "release"
+
+permissions:
+ contents: read
+
+jobs:
+ prebuild:
+ name: Prebuild Resources & Java (${{ inputs.build_type }})
+ runs-on: ubuntu-26.04
+ env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ with:
+ submodules: recursive
+ fetch-depth: 0
+
+ - name: Setup Java
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: "24"
+
+ - name: Setup Kotlin
+ uses: fwilhe2/setup-kotlin@88d53291848568823e529973af5d0cdb8ddf8d87 # main
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5
+
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
+ with:
+ packages: ""
+
+ - name: Cache NDK
+ id: cache-ndk
+ uses: actions/cache@v5
+ with:
+ path: ${{ env.ANDROID_SDK_ROOT }}/ndk/30.0.14904198
+ key: ${{ runner.os }}-ndk-30.0.14904198-v2
+ restore-keys: |
+ ${{ runner.os }}-ndk-30.0.14904198-v2-
+
+ - name: Install NDK
+ if: steps.cache-ndk.outputs.cache-hit != 'true'
+ run: sdkmanager "ndk;30.0.14904198"
+
+ - name: Configure local.properties
+ run: |
+ echo "sdk.dir=${ANDROID_SDK_ROOT}" > local.properties
+ echo "ndk.dir=${ANDROID_SDK_ROOT}/ndk/30.0.14904198" >> local.properties
+
+ - name: Compile sources & generate resources
+ shell: bash
+ run: |
+ set -eu
+ chmod +x gradlew
+ build_type="${{ inputs.build_type }}"
+ cap_type="$(tr '[:lower:]' '[:upper:]' <<< "${build_type:0:1}")${build_type:1}"
+
+ # Prebuild all module resources, Kotlin, Java, and AAR packages before native build finishes.
+ # The Gradle local build-cache will capture all compiled classes, generated resources, and DEX components.
+ ./gradlew --no-daemon --build-cache \
+ -Pupdate.channel="${{ inputs.channel }}" \
+ compile${cap_type}Sources \
+ :pack:bundle${cap_type}Aar
+
+ - name: Upload Gradle build cache
+ uses: actions/upload-artifact@v7
+ with:
+ name: gradle-build-cache-${{ inputs.channel }}
+ path: /home/runner/.gradle/caches/build-cache-1
+ if-no-files-found: error
+ compression-level: 0