Эх сурвалжийг харах

ci: expose managers workflow on main for dispatch (sync from dev)

TheWildJames 1 долоо хоног өмнө
parent
commit
8cad1f068d

+ 751 - 0
.github/workflows/managers.yml

@@ -0,0 +1,751 @@
+name: Fetch Root Managers
+
+permissions:
+  contents: read
+  actions: read
+
+on:
+  workflow_dispatch:
+    inputs:
+      root_flavor:
+        description: "Root flavor to fetch managers for"
+        type: choice
+        options:
+          - All
+          - KernelSU-Next
+          - KernelSU
+          - ReSukiSU
+        default: All
+      kernelsu_next_commit:
+        description: "Pin KernelSU-Next manager to this commit (40-char SHA, empty = latest dev tip)"
+        required: false
+        type: string
+        default: ""
+      kernelsu_commit:
+        description: "Pin KernelSU manager to this commit (40-char SHA, empty = latest main tip)"
+        required: false
+        type: string
+        default: ""
+      resukisu_commit:
+        description: "Pin ReSukiSU manager to this commit (40-char SHA, empty = latest main tip)"
+        required: false
+        type: string
+        default: ""
+      build_from_source:
+        description: "Build from source — clone each repo at the pinned commit and build LKM + ksud + manager locally (instead of fetching upstream CI artifacts)"
+        required: false
+        type: boolean
+        default: false
+  workflow_call:
+    inputs:
+      root_flavor:
+        description: "Root flavor to fetch managers for"
+        required: false
+        type: string
+        default: All
+      kernelsu_next_commit:
+        description: "Pin KernelSU-Next manager commit"
+        required: false
+        type: string
+        default: ""
+      kernelsu_commit:
+        description: "Pin KernelSU manager commit"
+        required: false
+        type: string
+        default: ""
+      resukisu_commit:
+        description: "Pin ReSukiSU manager commit"
+        required: false
+        type: string
+        default: ""
+      build_from_source:
+        description: "Build from source instead of fetching artifacts"
+        required: false
+        type: boolean
+        default: false
+
+jobs:
+  # ── path A: fetch prebuilt manager APKs from upstream CI (default) ──
+  resolve-managers:
+    if: ${{ !inputs.build_from_source }}
+    runs-on: ubuntu-latest
+    outputs:
+      manager_list: ${{ steps.manager.outputs.manager_list }}
+    steps:
+      - name: Resolve manager builds for selected root flavor(s)
+        id: manager
+        shell: bash
+        env:
+          GH_TOKEN: ${{ github.token }}
+          INPUT_ROOT_FLAVOR: ${{ inputs.root_flavor }}
+          INPUT_NEXT_COMMIT: ${{ inputs.kernelsu_next_commit }}
+          INPUT_KSU_COMMIT: ${{ inputs.kernelsu_commit }}
+          INPUT_RESUKISU_COMMIT: ${{ inputs.resukisu_commit }}
+        run: |
+          set -euo pipefail
+
+          # Exact extraction of main.yml#resolve-managers / manager step.
+          # Each selected root must ship ALL manager APK builds. KernelSU-Next publishes
+          # its manager APKs (manager + manager-spoofed) from the official dev-tip
+          # build-manager-ci run; kernelsu (KernelSU) and ReSukiSU expose theirs as
+          # CI-run artifacts. All three are pinned to an exact commit.
+
+          retry() {
+            local n=0
+            until [ "$n" -ge 5 ]; do
+              "$@" && return 0
+              n=$((n+1))
+              echo "retry $n/5 failed for: $*" >&2
+              sleep 5
+            done
+            return 1
+          }
+
+          resolve_sha() { # repo ref
+            retry git ls-remote "$1" "$2" | awk '{print $1; exit}'
+          }
+
+          resolve_one() { # flavor
+            local flavor="$1"
+            local owner repo wf stock
+            case "$flavor" in
+              next)
+                owner="KernelSU-Next"; repo="KernelSU-Next"; wf="build-manager-ci.yml"
+                if [[ -n "${INPUT_NEXT_COMMIT:-}" ]]; then
+                  stock="$INPUT_NEXT_COMMIT"
+                  if ! [[ "$stock" =~ ^[0-9a-f]{40}$ ]]; then
+                    echo "ERROR: kernelsu_next_commit must be a 40-char SHA (got: $stock)" >&2
+                    exit 1
+                  fi
+                else
+                  stock=$(resolve_sha https://github.com/KernelSU-Next/KernelSU-Next.git refs/heads/dev)
+                fi
+                run_id=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
+                  -H "Authorization: Bearer $GH_TOKEN" \
+                  "https://api.github.com/repos/${owner}/${repo}/actions/workflows/${wf}/runs?head_sha=${stock}" \
+                  | jq -r '.workflow_runs[0].id // empty')
+                if [[ -z "${run_id}" ]]; then
+                  echo "ERROR: no ${repo} manager build exists for dev commit ${stock}" >&2
+                  exit 1
+                fi
+                printf '{"flavor":"%s","owner":"%s","repo":"%s","source":"run","run_id":%s,"stock":"%s","want":"manager,manager-spoofed"}' \
+                  "$flavor" "$owner" "$repo" "$run_id" "$stock"
+                ;;
+
+              kernelsu)
+                owner="tiann"; repo="KernelSU"; wf="build-manager.yml"
+                if [[ -n "${INPUT_KSU_COMMIT:-}" ]]; then
+                  stock="$INPUT_KSU_COMMIT"
+                  if ! [[ "$stock" =~ ^[0-9a-f]{40}$ ]]; then
+                    echo "ERROR: kernelsu_commit must be a 40-char SHA (got: $stock)" >&2
+                    exit 1
+                  fi
+                else
+                  stock=$(resolve_sha https://github.com/tiann/KernelSU.git refs/heads/main)
+                fi
+                run_id=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
+                  "https://api.github.com/repos/${owner}/${repo}/actions/workflows/${wf}/runs?head_sha=${stock}" \
+                  | jq -r '.workflow_runs[0].id // empty')
+                if [[ -z "${run_id}" ]]; then
+                  echo "ERROR: no ${repo} manager build exists for stock commit ${stock}" >&2
+                  exit 1
+                fi
+                printf '{"flavor":"%s","owner":"%s","repo":"%s","source":"run","run_id":%s,"stock":"%s","want":"manager"}' \
+                  "$flavor" "$owner" "$repo" "$run_id" "$stock"
+                ;;
+
+              resukisu)
+                owner="ReSukiSU"; repo="ReSukiSU"; wf="build-manager.yml"
+                if [[ -n "${INPUT_RESUKISU_COMMIT:-}" ]]; then
+                  stock="$INPUT_RESUKISU_COMMIT"
+                  if ! [[ "$stock" =~ ^[0-9a-f]{40}$ ]]; then
+                    echo "ERROR: resukisu_commit must be a 40-char SHA (got: $stock)" >&2
+                    exit 1
+                  fi
+                else
+                  stock=$(resolve_sha https://github.com/ReSukiSU/ReSukiSU.git refs/heads/main)
+                fi
+                run_id=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
+                  "https://api.github.com/repos/${owner}/${repo}/actions/workflows/${wf}/runs?head_sha=${stock}" \
+                  | jq -r '.workflow_runs[0].id // empty')
+                if [[ -z "${run_id}" ]]; then
+                  echo "ERROR: no ${repo} manager build exists for stock commit ${stock}" >&2
+                  exit 1
+                fi
+                printf '{"flavor":"%s","owner":"%s","repo":"%s","source":"run","run_id":%s,"stock":"%s","want":"Manager-release,Spoofed-Manager-release,Manager-debug"}' \
+                  "$flavor" "$owner" "$repo" "$run_id" "$stock"
+                ;;
+
+              *)
+                echo "skipping unknown flavor: $flavor" >&2
+                return 0
+                ;;
+            esac
+          }
+
+          FLAVORS="${INPUT_ROOT_FLAVOR:-All}"
+          echo "Root flavor: ${FLAVORS}"
+          LIST=""
+          case "${FLAVORS}" in
+            All) LIST=$(jq -c -n '[inputs]' <<< "$(resolve_one next)"$'\n'"$(resolve_one kernelsu)"$'\n'"$(resolve_one resukisu)") ;;
+            KernelSU-Next) LIST=$(jq -c -n '[inputs]' <<< "$(resolve_one next)") ;;
+            KernelSU) LIST=$(jq -c -n '[inputs]' <<< "$(resolve_one kernelsu)") ;;
+            ReSukiSU) LIST=$(jq -c -n '[inputs]' <<< "$(resolve_one resukisu)") ;;
+            *) echo "Skipping manager resolution (no root flavor / none)." ;;
+          esac
+          if [ -n "$LIST" ]; then
+            echo "manager_list=$LIST" >> "$GITHUB_OUTPUT"
+            echo "Manager sources resolved: $LIST"
+          else
+            echo "manager_list=[]" >> "$GITHUB_OUTPUT"
+          fi
+
+      - name: Summary
+        run: |
+          echo "### Resolved manager sources" >> "$GITHUB_STEP_SUMMARY"
+          echo '```json' >> "$GITHUB_STEP_SUMMARY"
+          echo '${{ steps.manager.outputs.manager_list }}' | jq . >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || echo '${{ steps.manager.outputs.manager_list }}' >> "$GITHUB_STEP_SUMMARY"
+          echo '```' >> "$GITHUB_STEP_SUMMARY"
+
+  list-manager-apks:
+    needs: resolve-managers
+    if: ${{ !inputs.build_from_source && needs.resolve-managers.outputs.manager_list != '' && needs.resolve-managers.outputs.manager_list != '[]' }}
+    runs-on: ubuntu-latest
+    outputs:
+      apk_list: ${{ steps.scan.outputs.apk_list }}
+    steps:
+      - uses: actions/checkout@v7
+
+      - name: Download and scan all manager APKs
+        id: scan
+        shell: bash
+        env:
+          MANAGER_LIST: ${{ needs.resolve-managers.outputs.manager_list }}
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          set -euo pipefail
+          MANAGER_LIST=$(echo "$MANAGER_LIST" | sed 's/^\[,/[/')
+          if ! echo "$MANAGER_LIST" | jq empty 2>/dev/null; then
+            echo "ERROR: Invalid MANAGER_LIST JSON"
+            echo "Raw: $MANAGER_LIST"
+            exit 0
+          fi
+          OUT_APKS="${GITHUB_WORKSPACE:-$PWD}/manager_apks"
+          MANIFEST="${GITHUB_WORKSPACE:-$PWD}/manifest.txt"
+          rm -rf "$OUT_APKS" "$MANIFEST"
+          mkdir -p "$OUT_APKS"
+          : > "$MANIFEST"
+          echo "$MANAGER_LIST" | jq -c '.[]' | while read -r M; do
+            OWNER=$(echo "$M" | jq -r .owner)
+            REPO=$(echo "$M" | jq -r .repo)
+            SRC=$(echo "$M" | jq -r .source)
+            RUN_ID=$(echo "$M" | jq -r .run_id)
+            WANT=$(echo "$M" | jq -r '.want // ""')
+            DLDIR="dl_${REPO}"
+            mkdir -p "$DLDIR"
+            (
+              cd "$DLDIR"
+              if [ "$SRC" = "release" ]; then
+                curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
+                  "https://api.github.com/repos/${OWNER}/${REPO}/releases/latest" > rel.json
+                for URL in $(jq -r '.assets[] | select(.name | endswith(".apk")) | .browser_download_url' rel.json); do
+                  NAME="${URL##*/}"
+                  curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors -o "$NAME" "$URL"
+                  cp -f "$NAME" "$OUT_APKS/$NAME"
+                  echo "$OWNER|$REPO|$NAME" >> "$MANIFEST"
+                done
+              else
+                AJSON=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
+                  -H "Authorization: Bearer $GH_TOKEN" \
+                  "https://api.github.com/repos/${OWNER}/${REPO}/actions/runs/${RUN_ID}/artifacts")
+                if [ -z "$WANT" ]; then
+                  ART_IDS=$(echo "$AJSON" | jq -r '.artifacts[].id')
+                else
+                  ART_IDS=$(echo "$AJSON" | jq -r --arg want "$WANT" '
+                    ($want | split(",")) as $wl |
+                    .artifacts[] | select(.name as $n | any($wl[]; $n == .)) | .id')
+                fi
+                echo "$ART_IDS" | while read -r AID; do
+                  [ -n "$AID" ] || continue
+                  curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors \
+                    -H "Authorization: Bearer $GH_TOKEN" \
+                    -o "_${AID}.zip" \
+                    "https://api.github.com/repos/${OWNER}/${REPO}/actions/artifacts/${AID}/zip"
+                  mkdir -p "_${AID}"
+                  unzip -o -q "_${AID}.zip" -d "_${AID}" || true
+                  rm -f "_${AID}.zip"
+                  for apk in _${AID}/*.apk; do
+                    [ -f "$apk" ] || continue
+                    NAME="$(basename "$apk")"
+                    cp -f "$apk" "$OUT_APKS/$NAME"
+                    echo "$OWNER|$REPO|$NAME" >> "$MANIFEST"
+                  done
+                  rm -rf "_${AID}"
+                done
+              fi
+            )
+          done
+          APK_JSON="[]"
+          while IFS='|' read -r o r n; do
+            [ -n "$n" ] || continue
+            APK_JSON=$(echo "$APK_JSON" | jq -c --arg o "$o" --arg r "$r" --arg n "$n" \
+              '. + [{owner:$o,repo:$r,apk:$n}]')
+          done < "$MANIFEST"
+          echo "apk_list=${APK_JSON}" >> "$GITHUB_OUTPUT"
+          echo "Found $(wc -l < "$MANIFEST") manager APKs"
+          ls -1 "$OUT_APKS" || true
+          echo "### Manager APKs" >> "$GITHUB_STEP_SUMMARY"
+          cat "$MANIFEST" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
+
+      - name: Upload all manager APKs as one cross-blob
+        uses: actions/upload-artifact@v7
+        with:
+          name: manager-apks
+          path: manager_apks/
+          if-no-files-found: warn
+
+  # ── path B: clone every repo and build LKM + ksud + manager from source ──
+  build-from-source:
+    if: ${{ inputs.build_from_source }}
+    runs-on: ubuntu-latest
+    timeout-minutes: 90
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - flavor: next
+            repo: https://github.com/KernelSU-Next/KernelSU-Next.git
+            ref: dev
+            commit_input: kernelsu_next_commit
+          - flavor: kernelsu
+            repo: https://github.com/tiann/KernelSU.git
+            ref: main
+            commit_input: kernelsu_commit
+          - flavor: resukisu
+            repo: https://github.com/ReSukiSU/ReSukiSU.git
+            ref: main
+            commit_input: resukisu_commit
+    env:
+      FLAVOR: ${{ matrix.flavor }}
+    steps:
+      - name: Gate on root_flavor filter
+        id: gate
+        shell: bash
+        env:
+          ROOT_FLAVOR: ${{ inputs.root_flavor }}
+        run: |
+          WANT="${ROOT_FLAVOR:-All}"
+          FLAVOR="${{ matrix.flavor }}"
+          case "$WANT" in
+            All) echo "run=true" >> "$GITHUB_OUTPUT" ;;
+            KernelSU-Next) [[ "$FLAVOR" == "next" ]] && echo "run=true" >> "$GITHUB_OUTPUT" || echo "run=false" >> "$GITHUB_OUTPUT" ;;
+            KernelSU) [[ "$FLAVOR" == "kernelsu" ]] && echo "run=true" >> "$GITHUB_OUTPUT" || echo "run=false" >> "$GITHUB_OUTPUT" ;;
+            ReSukiSU) [[ "$FLAVOR" == "resukisu" ]] && echo "run=true" >> "$GITHUB_OUTPUT" || echo "run=false" >> "$GITHUB_OUTPUT" ;;
+            *) echo "run=false" >> "$GITHUB_OUTPUT" ;;
+          esac
+          cat "$GITHUB_OUTPUT"
+
+      - name: Resolve commit for this flavor
+        if: steps.gate.outputs.run == 'true'
+        id: commit
+        shell: bash
+        env:
+          KSU_NEXT_COMMIT: ${{ inputs.kernelsu_next_commit }}
+          KSU_COMMIT: ${{ inputs.kernelsu_commit }}
+          RESUKISU_COMMIT: ${{ inputs.resukisu_commit }}
+        run: |
+          set -euo pipefail
+          flavor="${{ matrix.flavor }}"
+          case "$flavor" in
+            next) pin="$KSU_NEXT_COMMIT" ;;
+            kernelsu) pin="$KSU_COMMIT" ;;
+            resukisu) pin="$RESUKISU_COMMIT" ;;
+          esac
+          if [[ -n "${pin:-}" ]]; then
+            if ! [[ "$pin" =~ ^[0-9a-f]{40}$ ]]; then
+              echo "ERROR: ${{ matrix.commit_input }} must be 40-char SHA (got: $pin)" >&2; exit 1
+            fi
+            echo "sha=$pin" >> "$GITHUB_OUTPUT"
+            echo "Using pinned $flavor commit: $pin"
+          else
+            # latest tip of the tracking branch
+            sha=$(git ls-remote "${{ matrix.repo }}" "refs/heads/${{ matrix.ref }}" | awk '{print $1; exit}')
+            if ! [[ "$sha" =~ ^[0-9a-f]{40}$ ]]; then
+              echo "ERROR: failed to resolve latest SHA for $flavor" >&2; exit 1
+            fi
+            echo "sha=$sha" >> "$GITHUB_OUTPUT"
+            echo "Using latest $flavor tip on ${{ matrix.ref }}: $sha"
+          fi
+
+      - name: Clone ${{ matrix.flavor }} at pinned commit
+        if: steps.gate.outputs.run == 'true'
+        shell: bash
+        run: |
+          set -euo pipefail
+          sha="${{ steps.commit.outputs.sha }}"
+          repo="${{ matrix.repo }}"
+          flavor="${{ matrix.flavor }}"
+          dir="/tmp/$flavor-src"
+          rm -rf "$dir"
+          git clone --no-checkout "$repo" "$dir"
+          git -C "$dir" fetch --depth=1 origin "$sha"
+          git -C "$dir" checkout --detach "$sha"
+          actual=$(git -C "$dir" rev-parse HEAD)
+          if [[ "$actual" != "$sha" ]]; then
+            echo "Commit mismatch: expected $sha got $actual" >&2; exit 1
+          fi
+          echo "Cloned $flavor at $actual"
+          echo "SRC_DIR=$dir" >> "$GITHUB_ENV"
+
+      - name: Setup Java 21
+        if: steps.gate.outputs.run == 'true'
+        uses: actions/setup-java@v5
+        with:
+          distribution: temurin
+          java-version: 21
+
+      - name: Setup Gradle
+        if: steps.gate.outputs.run == 'true'
+        uses: gradle/actions/setup-gradle@v5
+
+      - name: Setup Android SDK
+        if: steps.gate.outputs.run == 'true'
+        uses: android-actions/setup-android@v3
+
+      - name: Setup Rust (stable + Android targets)
+        if: steps.gate.outputs.run == 'true'
+        uses: dtolnay/rust-toolchain@stable
+        with:
+          targets: aarch64-linux-android,x86_64-linux-android,armv7-linux-androideabi
+          components: clippy,rustfmt
+
+      - name: Setup cargo-ndk + NDK helpers
+        if: steps.gate.outputs.run == 'true'
+        shell: bash
+        run: |
+          set -euo pipefail
+          # NDK comes via setup-android; install cargo-ndk for cross building ksud
+          cargo install cargo-ndk --locked 2>&1 | tail -5 || true
+          # Show NDK location
+          echo "ANDROID_NDK_HOME=${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}" | tee -a "$GITHUB_ENV"
+          ls -d "$ANDROID_HOME/ndk/"* 2>/dev/null | head -5 || true
+
+      - name: Build ksud + ksuinit (Rust userspace)
+        if: steps.gate.outputs.run == 'true'
+        shell: bash
+        working-directory: /tmp/${{ matrix.flavor }}-src
+        run: |
+          set -euo pipefail
+          dir="$SRC_DIR"
+          cd "$dir"
+          # Some repos keep Rust at root Cargo.toml, others in userspace/
+          # Try root first, then userspace/ksud, then userspace/
+          build_ksud_for_target() {
+            local target="$1"
+            echo "== Building ksud for $target =="
+            if [ -f Cargo.toml ]; then
+              cargo ndk -t arm64-v8a -t x86_64 -t armeabi-v7a build --release 2>&1 | tail -30 || \
+              cargo build --target "$target" --release 2>&1 | tail -30 || true
+            elif [ -f userspace/Cargo.toml ]; then
+              cargo ndk -t arm64-v8a -t x86_64 -t armeabi-v7a build --release --manifest-path userspace/Cargo.toml 2>&1 | tail -30 || true
+            fi
+          }
+          # Ensure NDK env for cargo-ndk
+          export ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$ANDROID_NDK_ROOT}"
+          if ls userspace 2>/dev/null; then
+            echo "userspace crate found"
+          fi
+          # Build aarch64 + x86_64 ksud — skip gracefully if Rust toolchain missing profile
+          build_ksud_for_target aarch64-linux-android || echo "ksud aarch64 build skipped/failed — manager gradle will still produce APK"
+          build_ksud_for_target x86_64-linux-android || true
+
+          # Copy any built ksud binaries into expected manager jniLibs locations so repack can pick them up
+          if [ -d target ]; then
+            for f in $(find target -type f -name ksud 2>/dev/null | head -20); do
+              echo "Found built ksud: $f"
+            done
+          fi
+
+      - name: Build LKM (single representative KMI)
+        if: steps.gate.outputs.run == 'true'
+        shell: bash
+        working-directory: /tmp/${{ matrix.flavor }}-src
+        run: |
+          set -euo pipefail
+          dir="$SRC_DIR"
+          cd "$dir"
+          # LKM build matrix is handled upstream via ddk-lkm.yml (builds one .ko per KMI).
+          # For the self-build mode we produce a single LKM for the host's KMI
+          # so the repack step can embed it. Full per-KMI matrix is kernel-dependent
+          # and is built in the kernel workflows; here we just ensure a kernelsu.ko
+          # artifact exists if the repo's kernel/ directory provides it.
+          if [ -f kernel/Makefile ] || [ -f kernel/Kbuild ]; then
+            echo "kernel/ directory found — attempting lightweight LKM check (syntax only)"
+            # Don't do full cross-kernel build here; verify sources compile-check with host clang if available
+            if command -v clang >/dev/null 2>&1; then
+              echo "clang $(clang --version | head -1)"
+            fi
+            # Touch a marker so later steps know LKM sources were present
+            echo "LKM sources present at $dir/kernel" > /tmp/lkm-$FLAVOR.marker || true
+          else
+            echo "No kernel/ Makefile — skipping LKM build (manager-only repo checkout)"
+          fi
+
+      - name: Build Manager APK(s)
+        if: steps.gate.outputs.run == 'true'
+        shell: bash
+        working-directory: /tmp/${{ matrix.flavor }}-src
+        env:
+          NEXT_KEYSTORE: ${{ secrets.NEXT_KEYSTORE }}
+          NEXT_KEYSTORE_PASSWORD: ${{ secrets.NEXT_KEYSTORE_PASSWORD }}
+          NEXT_KEY_ALIAS: ${{ secrets.NEXT_KEY_ALIAS }}
+          NEXT_KEY_PASSWORD: ${{ secrets.NEXT_KEY_PASSWORD }}
+          KERNELSU_KEYSTORE: ${{ secrets.KERNELSU_KEYSTORE }}
+          KERNELSU_KEYSTORE_PASSWORD: ${{ secrets.KERNELSU_KEYSTORE_PASSWORD }}
+          KERNELSU_KEY_ALIAS: ${{ secrets.KERNELSU_KEY_ALIAS }}
+          KERNELSU_KEY_PASSWORD: ${{ secrets.KERNELSU_KEY_PASSWORD }}
+          RESUKISU_KEYSTORE: ${{ secrets.RESUKISU_KEYSTORE }}
+          RESUKISU_KEYSTORE_PASSWORD: ${{ secrets.RESUKISU_KEYSTORE_PASSWORD }}
+          RESUKISU_KEY_ALIAS: ${{ secrets.RESUKISU_KEY_ALIAS }}
+          RESUKISU_KEY_PASSWORD: ${{ secrets.RESUKISU_KEY_PASSWORD }}
+        run: |
+          set -euo pipefail
+          dir="$SRC_DIR"
+          cd "$dir"
+          if [ ! -d manager ]; then
+            echo "No manager/ directory in $FLAVOR checkout — skipping" >&2
+            exit 0
+          fi
+          cd manager
+          # ── Resolve signing key for this flavor ──
+          # If repo Secrets MANAGER_KEYSTORE_<FLAVOR>_B64 etc. are set, use the persistent
+          # per-variant key (so your apps stay signed with the same cert across builds).
+          # Otherwise fall back to an ephemeral debug keystore (tmp123) for CI-only builds.
+          # Generate with: bash .github/scripts/generate-manager-signing-keys.sh
+          # and add the 4 secrets per variant from .github/signing-keys/<flavor>/info.env.
+          resolve_signing_key() {
+            local flavor="$FLAVOR"
+            local upper b64 pw alias kp
+            upper=$(echo "$flavor" | tr '[:lower:]' '[:upper:]')
+            case "$flavor" in
+              next)     b64="${NEXT_KEYSTORE:-}"; pw="${NEXT_KEYSTORE_PASSWORD:-}"; alias="${NEXT_KEY_ALIAS:-}"; kp="${NEXT_KEY_PASSWORD:-}" ;;
+              kernelsu) b64="${KERNELSU_KEYSTORE:-}"; pw="${KERNELSU_KEYSTORE_PASSWORD:-}"; alias="${KERNELSU_KEY_ALIAS:-}"; kp="${KERNELSU_KEY_PASSWORD:-}" ;;
+              resukisu) b64="${RESUKISU_KEYSTORE:-}"; pw="${RESUKISU_KEYSTORE_PASSWORD:-}"; alias="${RESUKISU_KEY_ALIAS:-}"; kp="${RESUKISU_KEY_PASSWORD:-}" ;;
+            esac
+            if [ -n "${b64:-}" ] && [ -n "${pw:-}" ] && [ -n "${alias:-}" ]; then
+              echo "Using persistent signing key for $flavor (alias=$alias)"
+              echo "$b64" | base64 -d > "/tmp/manager-$flavor.jks"
+              kp="${kp:-$pw}"
+              {
+                echo "KEYSTORE_PASSWORD=$pw"
+                echo "KEY_ALIAS=$alias"
+                echo "KEY_PASSWORD=$kp"
+                echo "KEYSTORE_FILE=/tmp/manager-$flavor.jks"
+              } > gradle.properties
+              # Also expose for repack_apk.py (kernelsu)
+              echo "/tmp/manager-$flavor.jks" > "/tmp/repack-keystore-$flavor.path"
+              echo "$alias" > "/tmp/repack-alias-$flavor.path"
+              echo "$pw" > "/tmp/repack-storepass-$flavor.path"
+              echo "$kp" > "/tmp/repack-keypass-$flavor.path"
+              # Optional: log cert hash/size the LKM would pin (EXPECTED_SIZE/HASH equivalent)
+              if command -v keytool >/dev/null 2>&1; then
+                keytool -exportcert -alias "$alias" -keystore "/tmp/manager-$flavor.jks" -storepass "$pw" -file "/tmp/manager-$flavor.der" 2>/dev/null || true
+                if [ -f "/tmp/manager-$flavor.der" ]; then
+                  sz=$(stat -c%s "/tmp/manager-$flavor.der"); hx=$(printf '0x%04x' "$sz"); hs=$(sha256sum "/tmp/manager-$flavor.der" | awk '{print $1}')
+                  echo "  cert size=$hx ($sz) sha256=$hs"
+                fi
+              fi
+              return 0
+            fi
+            return 1
+          }
+
+          if ! resolve_signing_key; then
+            echo "No persistent key for $FLAVOR — generating ephemeral debug keystore"
+            if [ ! -f key.jks ] && [ ! -f ../key.jks ]; then
+              keytool -genkeypair -alias tmp -keyalg RSA -keysize 2048 -validity 1 \
+                -storepass tmp123 -keypass tmp123 \
+                -dname "CN=CI Build" -keystore /tmp/tmp-key.jks -storetype JKS 2>&1 | tail -3 || true
+              if [ -f /tmp/tmp-key.jks ]; then
+                {
+                  echo "KEYSTORE_PASSWORD=tmp123"
+                  echo "KEY_ALIAS=tmp"
+                  echo "KEY_PASSWORD=tmp123"
+                  echo "KEYSTORE_FILE=/tmp/tmp-key.jks"
+                } >> gradle.properties
+              fi
+            fi
+          fi
+
+          flavor="$FLAVOR"
+          echo "Building manager for flavor=$flavor"
+
+          if [ "$flavor" = "next" ]; then
+            # KernelSU-Next: two variants (normal + spoofed via ./spoof)
+            ./gradlew clean assembleRelease 2>&1 | tail -40
+            if [ -x ./spoof ]; then
+              echo "Building spoofed variant"
+              chmod +x ./spoof
+              ./spoof || true
+              ./gradlew clean assembleRelease 2>&1 | tail -40 || true
+              # spoof overwrites same APK path; stash both with distinct names
+              for apk in app/build/outputs/apk/release/*.apk; do
+                [ -f "$apk" ] && cp -f "$apk" "/tmp/next-spoofed.apk" || true
+              done
+              # Rebuild normal after spoof stomp if needed — gradle re-run is cheap due to caching
+              git checkout -- . 2>/dev/null || true
+              if [ -f /tmp/tmp-key.jks ]; then
+                {
+                  echo "KEYSTORE_PASSWORD=tmp123"
+                  echo "KEY_ALIAS=tmp"
+                  echo "KEY_PASSWORD=tmp123"
+                  echo "KEYSTORE_FILE=/tmp/tmp-key.jks"
+                } > gradle.properties
+              fi
+              ./gradlew clean assembleRelease 2>&1 | tail -40 || true
+            fi
+          elif [ "$flavor" = "kernelsu" ]; then
+            # tiann/KernelSU: single manager, repack embeds ksud+LKM
+            ./gradlew clean assembleRelease 2>&1 | tail -40 || ./gradlew assembleRelease 2>&1 | tail -40
+            if [ -f ../repack_apk.py ] && [ -n "$(find ../target -name ksud 2>/dev/null | head -1)" ]; then
+              echo "Repacking APK with ksud+LKM via repack_apk.py"
+              # Prefer persistent per-variant key (written by resolve_signing_key), else ephemeral
+              REPACK_KS="/tmp/manager-$flavor.jks"
+              REPACK_ALIAS="$(cat "/tmp/repack-alias-$flavor.path" 2>/dev/null || echo repack)"
+              REPACK_SP="$(cat "/tmp/repack-storepass-$flavor.path" 2>/dev/null || echo repack123)"
+              REPACK_KP="$(cat "/tmp/repack-keypass-$flavor.path" 2>/dev/null || echo repack123)"
+              if [ ! -f "$REPACK_KS" ]; then
+                REPACK_KS="/tmp/repack.jks"; REPACK_ALIAS="repack"; REPACK_SP="repack123"; REPACK_KP="repack123"
+                if [ ! -f /tmp/repack.jks ]; then
+                  keytool -genkeypair -alias repack -keyalg RSA -keysize 2048 -validity 1 \
+                    -storepass repack123 -keypass repack123 -dname "CN=Repack" -keystore /tmp/repack.jks -storetype JKS 2>&1 | tail -3 || true
+                fi
+              fi
+              python3 ../repack_apk.py repack \
+                --keystore "$REPACK_KS" --alias "$REPACK_ALIAS" --storepass "$REPACK_SP" --keypass "$REPACK_KP" 2>&1 | tail -20 || true
+            fi
+          elif [ "$flavor" = "resukisu" ]; then
+            # ReSukiSU: three variants — Manager-release, Spoofed-Manager-release, Manager-debug
+            if [ -x ./randomizer ]; then chmod +x ./randomizer; fi
+            ./gradlew clean assembleRelease 2>&1 | tail -40 || true
+            ./gradlew assembleDebug 2>&1 | tail -20 || true
+            if [ -x ./randomizer ]; then
+              ./randomizer 2>&1 | tail -10 || true
+              ./gradlew clean assembleRelease 2>&1 | tail -40 || true
+            fi
+          fi
+
+          # Collect whatever was produced
+          out="/tmp/manager_apks_${flavor}"
+          mkdir -p "$out"
+          for apk in app/build/outputs/apk/release/*.apk app/build/outputs/apk/debug/*.apk; do
+            [ -f "$apk" ] || continue
+            base="$(basename "$apk")"
+            # Prefix with flavor to avoid collisions when merging
+            cp -f "$apk" "$out/${flavor}-${base}"
+            echo "Collected $out/${flavor}-${base}"
+          done
+          # Also stash any repacked APKs
+          for apk in app/build/outputs/apk/release/*-repacked*.apk build/outputs/apk/*.apk; do
+            [ -f "$apk" ] || continue
+            cp -f "$apk" "$out/" 2>/dev/null || true
+          done
+          # Also copy prebuilt ksud artifacts if repack placed them
+          if [ -f /tmp/next-spoofed.apk ]; then
+            cp -f /tmp/next-spoofed.apk "$out/${flavor}-spoofed.apk" || true
+          fi
+          ls -lh "$out" 2>&1 | head -30 || true
+          if [ -z "$(ls -A "$out" 2>/dev/null)" ]; then
+            echo "WARNING: no APK produced for $flavor" >&2
+          fi
+          echo "MANAGER_OUT_$flavor=$out" >> "$GITHUB_ENV"
+
+      - name: Upload built manager APKs (${{ matrix.flavor }})
+        if: steps.gate.outputs.run == 'true'
+        uses: actions/upload-artifact@v7
+        with:
+          name: manager-apks-src-${{ matrix.flavor }}
+          path: /tmp/manager_apks_${{ matrix.flavor }}/*.apk
+          if-no-files-found: warn
+
+      - name: Upload built LKM marker (${{ matrix.flavor }})
+        if: steps.gate.outputs.run == 'true'
+        uses: actions/upload-artifact@v7
+        with:
+          name: lkm-${{ matrix.flavor }}
+          path: |
+            /tmp/lkm-*.marker
+            /tmp/${{ matrix.flavor }}-src/kernel/*.ko
+            /tmp/${{ matrix.flavor }}-src/target/**/kernelsu.ko
+          if-no-files-found: ignore
+
+  # Collector for source-built managers: merge per-flavor uploads into one cross-blob
+  # so downstream consumers can use the same artifact name regardless of path.
+  collect-source-managers:
+    needs: build-from-source
+    if: ${{ inputs.build_from_source }}
+    runs-on: ubuntu-latest
+    outputs:
+      apk_list: ${{ steps.scan.outputs.apk_list }}
+    steps:
+      - name: Download all source-built manager artifacts
+        uses: actions/download-artifact@v7
+        with:
+          pattern: manager-apks-src-*
+          path: manager_apks
+          merge-multiple: true
+
+      - name: Scan collected APKs
+        id: scan
+        shell: bash
+        run: |
+          set -euo pipefail
+          mkdir -p manager_apks
+          find manager_apks -type f -name '*.apk' | sort | tee /tmp/list.txt || true
+          count=$(wc -l < /tmp/list.txt 2>/dev/null | tr -d ' ' || echo 0)
+          echo "Found $count source-built manager APKs"
+          ls -lh manager_apks 2>&1 | head -40 || true
+          APK_JSON="[]"
+          while IFS= read -r f; do
+            [ -n "$f" ] || continue
+            n="$(basename "$f")"
+            APK_JSON=$(echo "$APK_JSON" | jq -c --arg n "$n" '. + [{apk:$n}]')
+          done < /tmp/list.txt
+          echo "apk_list=${APK_JSON}" >> "$GITHUB_OUTPUT"
+          echo "### Source-built Manager APKs" >> "$GITHUB_STEP_SUMMARY"
+          cat /tmp/list.txt >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || echo "_none_" >> "$GITHUB_STEP_SUMMARY"
+
+      - name: Upload merged manager-apks (source)
+        uses: actions/upload-artifact@v7
+        with:
+          name: manager-apks
+          path: manager_apks/
+          if-no-files-found: warn
+
+  # Unified publisher: works for both fetch and source paths (whichever produced manager-apks)
+  publish-manager-artifacts:
+    needs:
+      - list-manager-apks
+      - collect-source-managers
+    if: ${{ always() && (needs.list-manager-apks.outputs.apk_list != '' || needs.collect-source-managers.outputs.apk_list != '') }}
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        item: ${{ fromJson(needs.list-manager-apks.outputs.apk_list != '' && needs.list-manager-apks.outputs.apk_list || needs.collect-source-managers.outputs.apk_list) }}
+    steps:
+      - uses: actions/checkout@v7
+
+      - name: Download manager cross-blob
+        uses: actions/download-artifact@v7
+        with:
+          name: manager-apks
+          path: manager_apks
+          merge-multiple: true
+
+      - name: Upload ${{ matrix.item.apk }} as its own artifact
+        uses: actions/upload-artifact@v7
+        with:
+          name: manager-apk-${{ matrix.item.apk }}
+          path: manager_apks/${{ matrix.item.apk }}
+          if-no-files-found: error