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