ci(android): 一个包在每个 Android 版本上跑,从 10 开始 #11
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Android compatibility | |
| # One APK, every Android version we claim to support. | |
| # | |
| # `native-smoke` asks whether the templates can produce a launching app at all, | |
| # and it asks it on ONE platform version — API 34. That leaves the failure this | |
| # exists for completely uncovered: a host that runs on the version CI happens to | |
| # boot and dies on the ones users have. A packaged game is built ONCE here and | |
| # installed on one emulator per Android release, so a version-specific break is | |
| # attributed to the version rather than to the build. | |
| # | |
| # The frames are collected to be LOOKED AT. There is deliberately no pixel | |
| # judgement: a scene that is legitimately dark and a renderer that died produce | |
| # the same dark PNG, and no threshold separates them. What this gates is what can | |
| # be decided without a human — did it install, did the host reach `ready`, did the | |
| # boot record name an error. Everything else is measured, tabulated, and left for | |
| # a reviewer. | |
| # | |
| # The version list is DISCOVERED from the runner's own system images rather than | |
| # written down, so a new Android release is picked up by the next run instead of | |
| # by whoever remembers to edit this file. Preview releases are named by codename | |
| # and are skipped on purpose — a matrix that reds on an unreleased platform gets | |
| # ignored within a week. | |
| # | |
| # What a hosted runner cannot see: real GPU drivers, compressed texture support, | |
| # thermal throttling and memory pressure. Dawn runs on the emulator's SwiftShader, | |
| # so the frame times are a CPU rasteriser's. Comparing versions against each other | |
| # is valid; reading any number here as device performance is not. The report says | |
| # so on every run rather than trusting anyone to remember it. | |
| on: | |
| pull_request: | |
| paths: | |
| - 'native/**' | |
| - 'build-tools/**' | |
| - 'sdk/**' | |
| - 'toolchain.manifest.json' | |
| - '.github/workflows/android-compat.yml' | |
| - 'tools/verify-native-boot.mjs' | |
| - 'tools/android-compat-report.mjs' | |
| workflow_dispatch: | |
| inputs: | |
| examples: | |
| description: 'Comma-separated example names to package and run on every version.' | |
| required: false | |
| default: 'space-shooter' | |
| type: string | |
| api-floor: | |
| description: 'Oldest API level to test (29 = Android 10).' | |
| required: false | |
| default: '29' | |
| type: string | |
| template-source: | |
| description: 'Which runtime template to wrap: the latest release, this branch (~35 min), or decide from what the PR touches.' | |
| required: false | |
| default: auto | |
| type: choice | |
| options: [auto, release, head] | |
| permissions: | |
| contents: read | |
| # One matrix per PR at a time. `paths` on a pull_request matches the PR's whole | |
| # diff, not the latest push, so every push re-runs all eight emulators — three | |
| # pushes queued twenty-four of them before this was here. | |
| concurrency: | |
| group: android-compat-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| # Android 10. The manifest claims 26, which is a wider promise than anything | |
| # here checks — raising this floor is a decision about what we support, so it is | |
| # stated once, in one place, rather than implied by a matrix. | |
| ANDROID_API_FLOOR: ${{ inputs.api-floor || '29' }} | |
| ANDROID_PROFILE: pixel_6 | |
| CANARY_EXAMPLES: ${{ inputs.examples || 'space-shooter' }} | |
| jobs: | |
| # --------------------------------------------------------------------------- | |
| versions: | |
| name: Which Android versions can this runner boot | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| outputs: | |
| matrix: ${{ steps.find.outputs.matrix }} | |
| count: ${{ steps.find.outputs.count }} | |
| steps: | |
| - name: List the system images the SDK actually offers | |
| id: find | |
| run: | | |
| set -euo pipefail | |
| SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" | |
| # Digits only, so preview platforms (android-Baklava and the like) are | |
| # left out rather than reddening the matrix on an unreleased release. | |
| AVAILABLE=$("$SDKMANAGER" --list 2>/dev/null \ | |
| | grep -oE 'system-images;android-[0-9]+;google_apis;x86_64' \ | |
| | grep -oE 'android-[0-9]+' | grep -oE '[0-9]+' | sort -un || true) | |
| if [ -z "$AVAILABLE" ]; then | |
| echo "::error::sdkmanager listed no google_apis;x86_64 system images at all." | |
| exit 1 | |
| fi | |
| LEVELS=$(echo "$AVAILABLE" | awk -v floor="$ANDROID_API_FLOOR" '$1 >= floor') | |
| if [ -z "$LEVELS" ]; then | |
| echo "::error::no system image at or above API $ANDROID_API_FLOOR. This runner has: $(echo $AVAILABLE | tr '\n' ' ')" | |
| exit 1 | |
| fi | |
| # The floor itself must exist. If the runner image stops carrying the | |
| # oldest version we claim to support, that is a silent loss of coverage | |
| # unless it fails here and says so. | |
| if ! echo "$LEVELS" | grep -qx "$ANDROID_API_FLOOR"; then | |
| echo "::error::API $ANDROID_API_FLOOR (the declared floor) has no system image on this runner. Available: $(echo $LEVELS | tr '\n' ' ')" | |
| exit 1 | |
| fi | |
| MATRIX=$(echo "$LEVELS" | jq -Rsc 'split("\n") | map(select(length > 0) | tonumber)') | |
| echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" | |
| echo "count=$(echo "$LEVELS" | wc -l | tr -d ' ')" >> "$GITHUB_OUTPUT" | |
| echo "Testing API levels: $(echo $LEVELS | tr '\n' ' ')" | |
| # --------------------------------------------------------------------------- | |
| apk: | |
| name: Package the canary example(s) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 120 | |
| permissions: | |
| contents: read | |
| pull-requests: read # to see which files the PR touches | |
| outputs: | |
| # Reported alongside the numbers: "the shipped binary" and "a build of this | |
| # branch" are different claims, and a table that does not say which it made | |
| # is a table nobody can act on. | |
| template-from: ${{ steps.src.outputs.from }} | |
| steps: | |
| # Submodules only matter to a from-source build; the released template | |
| # carries its libraries already. Fetched unconditionally because a | |
| # conditional checkout is a second way for the two paths to diverge. | |
| - uses: actions/checkout@v7 | |
| with: | |
| submodules: recursive | |
| - uses: ./.github/actions/setup | |
| # The SDK bundle is compiled INTO the host binary, so a template built here | |
| # needs it — and the export bundles the game against it either way. | |
| - name: Build SDK | |
| run: pnpm --filter ./sdk build | |
| # Building the host takes ~35 minutes (Dawn, once per ABI) and produces | |
| # something byte-for-byte irrelevant unless the host source actually changed. | |
| # A released template was already built, on a machine that did nothing else, | |
| # and it has the stronger claim besides: it is the binary users installed, so | |
| # a crash reproduced against it is THE crash rather than a rebuild of it. | |
| # | |
| # So: from source only when this PR touches the host, and from the release | |
| # otherwise. Getting this backwards is silent — a native fix "verified" | |
| # against a template that predates it — hence the decision is logged. | |
| - name: Decide where the template comes from | |
| id: src | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| WANT='${{ inputs.template-source }}' | |
| if [ -z "$WANT" ] || [ "$WANT" = 'auto' ]; then | |
| if [ "$GITHUB_EVENT_NAME" = 'pull_request' ]; then | |
| CHANGED=$(gh pr diff '${{ github.event.pull_request.number }}' --name-only) | |
| # build-tools/ counts: the API level the host compiles against is a | |
| # DEFAULT in build-tools, not a value in native/. The change that fixed | |
| # the Android 10 crash touched nothing under native/, so a rule that | |
| # watched only native/ would have tested the old released binary and | |
| # reported the fix as not working. | |
| if echo "$CHANGED" | grep -qE '^(native/|build-tools/|toolchain\.manifest\.json)'; then | |
| WANT=head | |
| echo "this PR touches the native host — the template must be built from it" | |
| else | |
| WANT=release | |
| echo "this PR touches no native host source — using the released template" | |
| fi | |
| else | |
| WANT=release | |
| fi | |
| fi | |
| echo "from=$WANT" >> "$GITHUB_OUTPUT" | |
| - name: Take the template from the latest release | |
| if: steps.src.outputs.from == 'release' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName) | |
| echo "template from $TAG" | |
| mkdir -p artifacts | |
| gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ | |
| -p 'estella-native-android-*.zip' -D artifacts | |
| - name: Read the native dependency pins | |
| if: steps.src.outputs.from == 'head' | |
| id: pins | |
| run: | | |
| node -e "const p=require('./toolchain.manifest.json').native;\ | |
| console.log('dawn='+p.dawn.commit);console.log('quickjs='+p.quickjs.commit)" >> "$GITHUB_OUTPUT" | |
| # restore-keys without the pins, because a Dawn build for a DIFFERENT pin is | |
| # still most of this one's object files. Note the release pipeline's own cache | |
| # is written from a tag ref and is therefore unreachable from any branch — | |
| # see the PR discussion; warming this from master is separate work. | |
| - name: Cache the pinned checkouts + Dawn builds | |
| if: steps.src.outputs.from == 'head' | |
| uses: actions/cache@v4 | |
| with: | |
| path: build/native-deps | |
| key: native-deps-android-${{ steps.pins.outputs.dawn }}-${{ steps.pins.outputs.quickjs }} | |
| restore-keys: native-deps-android- | |
| - name: Set up the Android NDK | |
| if: steps.src.outputs.from == 'head' | |
| uses: nttld/setup-ndk@v1 | |
| id: ndk | |
| with: | |
| ndk-version: r28 | |
| - name: Fetch Dawn + QuickJS at their pinned commits | |
| if: steps.src.outputs.from == 'head' | |
| run: node build-tools/cli.js native --fetch-deps | |
| # BOTH ABIs, even though every emulator here is x86_64: ANDROID_ABIS names | |
| # arm64-v8a and x86_64 as what a template must carry, and the emitter refuses | |
| # an incomplete one. An x86_64-only build fails at the emit step, not at the | |
| # compile — which is exactly how this was found. | |
| - name: Build the Android runtime template from this branch | |
| if: steps.src.outputs.from == 'head' | |
| env: | |
| ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }} | |
| run: | | |
| node build-tools/cli.js native --abi arm64-v8a | |
| node build-tools/cli.js native --abi x86_64 --template-out artifacts | |
| - name: Unpack the template | |
| run: | | |
| node build-tools/cli.js verify-template artifacts/*.zip | |
| mkdir -p template | |
| unzip -q artifacts/*.zip -d template | |
| # Packaged the way the editor's Package dialog does it, once: every version | |
| # in the matrix installs the identical file, so a difference between two rows | |
| # cannot be a difference between two builds. | |
| - name: Package the canary example(s) | |
| run: | | |
| set -euo pipefail | |
| mkdir -p apks | |
| IFS=',' read -ra NAMES <<< "$CANARY_EXAMPLES" | |
| for name in "${NAMES[@]}"; do | |
| name=$(echo "$name" | xargs) | |
| [ -z "$name" ] && continue | |
| echo "::group::packaging $name" | |
| node desktop/scripts/export-project.mjs "examples/$name" \ | |
| --platform android --template template \ | |
| --out "build/pkg/$name" --json "build/pkg/$name.json" | |
| APK=$(node -e "process.stdout.write(require('./build/pkg/$name.json').apkFile || '')") | |
| if [ -z "$APK" ]; then | |
| echo "::error::the export of $name wrote no APK" | |
| exit 1 | |
| fi | |
| cp "$APK" "apks/$name.apk" | |
| echo "::endgroup::" | |
| done | |
| ls -la apks | |
| - uses: actions/upload-artifact@v7 | |
| with: | |
| name: compat-apks | |
| path: apks/*.apk | |
| retention-days: 3 | |
| # --------------------------------------------------------------------------- | |
| compat: | |
| name: Android API ${{ matrix.api }} | |
| needs: [versions, apk] | |
| runs-on: ubuntu-latest | |
| # A version that works finishes in about three minutes and one that crashes in | |
| # seven. API 29 once failed to boot at all and sat here for the full 45, so the | |
| # cap is set near what a real run costs: a stuck emulator should be reported as | |
| # "no data" quickly, not held open in case it recovers. | |
| timeout-minutes: 20 | |
| strategy: | |
| # A compatibility matrix whose whole output is "which versions work" must | |
| # run every version even after one fails. fail-fast here would report the | |
| # oldest break and hide everything above it. | |
| fail-fast: false | |
| matrix: | |
| api: ${{ fromJSON(needs.versions.outputs.matrix) }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: ./.github/actions/setup | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| name: compat-apks | |
| path: apks | |
| # Without KVM the emulator falls back to software CPU emulation and the boot | |
| # alone outlasts the job. | |
| - name: Enable KVM | |
| run: | | |
| echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | |
| | sudo tee /etc/udev/rules.d/99-kvm4all.rules | |
| sudo udevadm control --reload-rules | |
| sudo udevadm trigger --name-match=kvm | |
| # Attempted twice, because "the app crashed" and "the emulator never came up" | |
| # both fail this step and only one of them is an answer. Older API levels | |
| # download their system image and then sometimes never reach | |
| # sys.boot_completed at all — API 29 and API 30 traded places across three | |
| # runs, costing a version each time, which a matrix built to say "every | |
| # version works" cannot afford to do at random. | |
| # | |
| # `continue-on-error` on the first go, and the retry is conditional on | |
| # NOTHING having been filed: a metrics file means the script ran and reached | |
| # a verdict, even a crash, and re-running that would only waste an emulator. | |
| - name: Install, launch and measure on API ${{ matrix.api }} | |
| id: attempt1 | |
| continue-on-error: true | |
| uses: reactivecircus/android-emulator-runner@v2 | |
| with: | |
| api-level: ${{ matrix.api }} | |
| target: google_apis | |
| arch: x86_64 | |
| profile: ${{ env.ANDROID_PROFILE }} | |
| emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -memory 4096 -cores 2 | |
| # ONE line, because this action runs `script` as a separate `sh -c` per | |
| # line: a loop written here arrives split and dies on its own `do`, and | |
| # nothing set on one line is visible on the next. The work lives in a | |
| # file that can also be run by hand. | |
| script: bash tools/android-compat-run.sh ${{ matrix.api }} | |
| - name: Did anything get measured | |
| id: measured | |
| run: | | |
| if ls build/compat/*.json >/dev/null 2>&1; then | |
| echo "any=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "any=false" >> "$GITHUB_OUTPUT" | |
| echo "nothing was filed — the emulator never came up, so this is not yet a result" | |
| fi | |
| - name: Try API ${{ matrix.api }} once more | |
| if: steps.measured.outputs.any == 'false' | |
| uses: reactivecircus/android-emulator-runner@v2 | |
| continue-on-error: true | |
| with: | |
| api-level: ${{ matrix.api }} | |
| target: google_apis | |
| arch: x86_64 | |
| profile: ${{ env.ANDROID_PROFILE }} | |
| emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -memory 4096 -cores 2 | |
| script: bash tools/android-compat-run.sh ${{ matrix.api }} | |
| - name: Keep the frame, the record and the numbers | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: compat-api${{ matrix.api }} | |
| path: build/compat | |
| if-no-files-found: warn | |
| # This job MEASURES; the report JUDGES. So it fails only when it measured | |
| # nothing at all — a crashed version is a successful measurement of a broken | |
| # platform, and letting that redden the job here would put the verdict in two | |
| # places that can disagree. | |
| - name: Fail only if this version was never measured | |
| if: always() | |
| run: | | |
| if ls build/compat/*.json >/dev/null 2>&1; then | |
| echo "measured API ${{ matrix.api }}" | |
| else | |
| echo "::error::API ${{ matrix.api }} produced no data after two attempts" | |
| exit 1 | |
| fi | |
| # --------------------------------------------------------------------------- | |
| report: | |
| name: Report into the PR | |
| needs: [versions, apk, compat] | |
| # always(): the versions that FAILED are the rows this report exists to show. | |
| # A summariser that only runs on success reports exactly the case nobody needs. | |
| if: always() && needs.versions.result == 'success' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| permissions: | |
| contents: write # publishes the frames to a branch the PR can render | |
| pull-requests: write # posts the table | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # The report itself is node builtins only, but the toolchain version it runs | |
| # under is this action's to decide, not the runner image's. | |
| - uses: ./.github/actions/setup | |
| - uses: actions/download-artifact@v8 | |
| with: | |
| pattern: compat-api* | |
| path: build/compat | |
| # A fork's token is read-only, so neither the push nor the comment can work. | |
| # Say that in the log and still build the report, rather than failing with a | |
| # permissions error that looks like a broken workflow. | |
| - name: Can this run write to the repo | |
| id: writable | |
| run: | | |
| if [ "${{ github.event_name }}" = 'pull_request' ] \ | |
| && [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then | |
| echo "same-repo=false" >> "$GITHUB_OUTPUT" | |
| echo "PR is from a fork — the token cannot push frames or comment." | |
| else | |
| echo "same-repo=true" >> "$GITHUB_OUTPUT" | |
| fi | |
| # GitHub renders an image in a comment only from a URL. Artifacts have none, | |
| # so the frames go to an orphan branch and are referenced from there. The | |
| # github.com/raw form (not raw.githubusercontent) is used because it resolves | |
| # for a signed-in reviewer even when the repository is private. | |
| - name: Publish the frames where the PR can show them | |
| id: shots | |
| if: steps.writable.outputs.same-repo == 'true' | |
| env: | |
| BRANCH: ci-android-shots | |
| DEST: pr-${{ github.event.pull_request.number || 'dispatch' }}/${{ github.run_id }} | |
| run: | | |
| set -euo pipefail | |
| shopt -s nullglob globstar | |
| FRAMES=(build/compat/**/*.png) | |
| if [ ${#FRAMES[@]} -eq 0 ]; then | |
| echo "no frames were captured — nothing to publish" | |
| exit 0 | |
| fi | |
| git config user.name 'github-actions[bot]' | |
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | |
| if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then | |
| git fetch --depth=1 origin "$BRANCH:$BRANCH" | |
| git worktree add shots "$BRANCH" | |
| else | |
| git worktree add --detach shots | |
| git -C shots checkout --orphan "$BRANCH" | |
| git -C shots rm -rf --cached . >/dev/null 2>&1 || true | |
| rm -rf shots/* 2>/dev/null || true | |
| fi | |
| mkdir -p "shots/$DEST" | |
| cp "${FRAMES[@]}" "shots/$DEST/" | |
| git -C shots add -A | |
| git -C shots commit -q -m "ci: android compat frames for ${DEST}" | |
| git -C shots push -q origin "$BRANCH" | |
| echo "base=https://github.com/${{ github.repository }}/raw/$BRANCH/$DEST" >> "$GITHUB_OUTPUT" | |
| # continue-on-error because a non-zero exit here IS the verdict — it means | |
| # some version is broken or untested — and the comment still has to be | |
| # posted. The gate at the end of this job reads the outcome. | |
| - name: Build the table | |
| id: table | |
| continue-on-error: true | |
| run: | | |
| node tools/android-compat-report.mjs \ | |
| --dir build/compat \ | |
| --template-source '${{ needs.apk.outputs.template-from }}' \ | |
| --expect '${{ needs.versions.outputs.matrix }}' \ | |
| ${{ steps.shots.outputs.base && format('--shots-base {0}', steps.shots.outputs.base) || '' }} \ | |
| --out compat-comment.md | |
| cat compat-comment.md >> "$GITHUB_STEP_SUMMARY" | |
| # Edited in place on re-runs: a PR that pushes six times should carry one | |
| # current table, not six stale ones. | |
| - name: Post it on the PR | |
| if: github.event_name == 'pull_request' && steps.writable.outputs.same-repo == 'true' | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| PR: ${{ github.event.pull_request.number }} | |
| run: | | |
| set -euo pipefail | |
| EXISTING=$(gh api "repos/${{ github.repository }}/issues/$PR/comments" --paginate \ | |
| --jq '.[] | select(.body | startswith("<!-- android-compat -->")) | .id' | head -1) | |
| if [ -n "$EXISTING" ]; then | |
| gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$EXISTING" \ | |
| -F body=@compat-comment.md >/dev/null | |
| echo "updated comment $EXISTING" | |
| else | |
| gh api -X POST "repos/${{ github.repository }}/issues/$PR/comments" \ | |
| -F body=@compat-comment.md >/dev/null | |
| echo "posted a new comment" | |
| fi | |
| # The gate, after the report rather than before it: a red check with no table | |
| # tells a reviewer that something broke but not what. It reads the DATA, not | |
| # the matrix jobs' conclusions — those cannot tell a crashed app from an | |
| # emulator that never booted, and this distinction is the whole point. | |
| - name: Fail if any version is broken or untested | |
| if: steps.table.outcome == 'failure' | |
| run: | | |
| echo "::error::some Android version is broken or was never measured — see the table in the PR comment" | |
| exit 1 |